mamsc
Version:
MIDI Schow Control over Ethernet for MA lighting
655 lines (557 loc) • 19.8 kB
JavaScript
'use strict'
/**
* MIDI Schow Control over Ethernet for MA lighting
* @module mamsc
* @license
* Copyright (C) 2018 Christian Volmering <christian@volmering.name>
* Licensed under the MIT and GPL-3.0 licenses.
*/
const EventEmitter = require('events')
const dgram = require('dgram')
const MSG_ID = 0x474d4100
const MSG_TYPE = 0x4d534300
const MSG_RTSE = 0xf07f
const MSG_MSC = 0x02
const MSG_EOX = 0xf7
const MSG_ALL = 0x7f
const socketPool = new WeakMap()
const Command = Object.freeze({
GO: 0x01,
STOP: 0x02,
RESUME: 0x03,
TIMED: 0x04,
SET: 0x06,
FIRE: 0x07,
OFF: 0x0a
})
class ProtocolError extends Error {
constructor (message) {
super(message)
this.name = this.constructor.name
Error.captureStackTrace(this, this.constructor)
}
}
class Message {
constructor (command, data, config) {
this.command = command || 'noop'
this.data = data || {}
if (!config) {
this.target = MSG_ALL
} else {
this.setTarget(config)
}
}
static fromBuffer (buffer) {
return new Message().readBuffer(buffer)
}
static toBuffer (command, data, config) {
return new Message(command, data, config).writeBuffer()
}
checkTarget (config) {
return this.target === MSG_ALL ||
(this.target === config.deviceId || this.target === (config.groupId + 0x6f))
}
setTarget (config) {
switch (config.sendTo) {
case 'all': this.target = MSG_ALL
break
case 'device': this.target = config.deviceId
break
case 'group': this.target = config.groupId + 0x6f
break
default:
throw new Error('Invalid target: ' + config.sendTo)
}
}
readHeader (buffer) {
if (MSG_ID !== buffer.readInt32BE(0x00) || MSG_TYPE !== buffer.readInt32BE(0x04)) {
throw new ProtocolError('Message has no MA signature')
}
if (MSG_RTSE !== buffer.readUInt16BE(0x0c)) {
throw new ProtocolError('Invalid SysEx header')
}
if (MSG_MSC !== buffer.readUInt8(0x0f)) {
throw new ProtocolError('Not a MIDI Show Control message')
}
if (MSG_ALL !== buffer.readUInt8(0x10)) {
throw new ProtocolError('Unsupported command format')
}
this.target = buffer.readUInt8(0x0e)
return {
length: buffer.readInt32LE(0x08),
command: buffer.readUInt8(0x11)
}
}
readFadeTime (buffer, offset) {
const hour = buffer.readUInt8(offset)
const minute = buffer.readUInt8(++offset)
const second = buffer.readUInt8(++offset)
const frame = buffer.readUInt8(++offset)
return (hour * 3600) + (minute * 60) + second + (frame / 24)
}
readExec (buffer, length, timed) {
let [cue, exec] = buffer.toString('ascii', timed ? 0x17 : 0x12, length - 1).split('\0')
let [number, page] = exec.split('.')
this.data.exec = --number + '.' + page
if ((cue = Number(cue))) {
this.data.cue = cue.toFixed(3)
}
if (timed) {
this.data.fade = this.readFadeTime(buffer, 0x12)
}
return this
}
readFader (buffer) {
const fine = buffer.readUInt8(0x14)
const coarse = buffer.readUInt8(0x15)
const value = coarse * 128 + fine
this.data.position = { percent: Number((100 / 0x3fff * value).toFixed(2)), value }
this.data.exec = buffer.readUInt8(0x12) + '.' + buffer.readUInt8(0x13)
if (MSG_EOX !== buffer.readUInt8(0x16)) {
this.data.fade = this.readFadeTime(buffer, 0x16)
}
return this
}
readBuffer (buffer) {
let length, command
try {
({ length, command } = this.readHeader(buffer))
} catch (err) {
if (err instanceof RangeError) {
throw new ProtocolError('Malformed message')
} else {
throw err
}
}
if (MSG_EOX !== buffer.readUInt8(length - 1)) {
throw new ProtocolError('Invalid message length')
}
switch (command) {
case Command.GO: this.command = 'goto'
return this.readExec(buffer, length)
case Command.STOP: this.command = 'pause'
return this.readExec(buffer, length)
case Command.RESUME: this.command = 'resume'
return this.readExec(buffer, length)
case Command.TIMED: this.command = 'goto'
return this.readExec(buffer, length, true)
case Command.SET: this.command = 'fader'
return this.readFader(buffer)
case Command.OFF: this.command = 'off'
return this.readExec(buffer, length)
default:
throw new ProtocolError('Invalid or unsupported command: 0x' + Number(command).toString(16))
}
}
writeHeader (buffer, length, command) {
buffer.writeInt32BE(MSG_ID, 0x00)
buffer.writeInt32BE(MSG_TYPE, 0x04)
buffer.writeInt32LE(length, 0x08)
buffer.writeUInt16BE(MSG_RTSE, 0x0c)
buffer.writeUInt8(this.target, 0x0e)
buffer.writeUInt8(MSG_MSC, 0x0f)
buffer.writeUInt8(MSG_ALL, 0x10)
buffer.writeUInt8(command, 0x11)
}
writeFadeTime (buffer, offset, time) {
const hour = time / 3600
const minute = (hour % 1) * 60
const second = (minute % 1) * 60
buffer.writeUInt8(Math.trunc(hour), offset)
buffer.writeUInt8(Math.trunc(minute), ++offset)
buffer.writeUInt8(Math.trunc(second), ++offset)
buffer.writeUInt16BE(0, ++offset)
return ++offset
}
writeExec (buffer, command) {
let [exec, page] = String(this.data.exec || 0.1).split('.')
let offset = 0x12
if (this.data.fade) {
offset = this.writeFadeTime(buffer, offset, this.data.fade) + 1
}
offset += buffer.write(Number(this.data.cue || 0).toFixed(3), offset)
offset += buffer.write(++exec + '.' + (page || 1), ++offset)
buffer.writeUInt8(MSG_EOX, ++offset)
this.writeHeader(buffer, offset, command)
return buffer.slice(0, ++offset)
}
writeFader (buffer) {
const { percent = this.data.position || 0, value } = this.data.position
const coarse = (value >= 0 ? value : 0x3fff / 100 * percent) / 128
const fine = (coarse % 1) * 128
const [exec, page] = String(this.data.exec || 0.1).split('.')
buffer.writeUInt8(exec, 0x12)
buffer.writeUInt8(page || 1, 0x13)
buffer.writeUInt8(Math.trunc(fine), 0x14)
buffer.writeUInt8(Math.trunc(coarse), 0x15)
let offset = 0x16
if (this.data.fade) {
offset = this.writeFadeTime(buffer, offset, this.data.fade)
}
buffer.writeUInt8(MSG_EOX, ++offset)
this.writeHeader(buffer, offset, Command.SET)
return buffer.slice(0, ++offset)
}
writeFire (buffer) {
const macro = Number(this.data.macro || 0)
buffer.writeUInt8(macro.toFixed(), 0x12)
buffer.writeUInt8(MSG_EOX, 0x13)
this.writeHeader(buffer, 0x13, Command.FIRE)
return buffer.slice(0, 0x14)
}
writeBuffer () {
const buffer = Buffer.alloc(64, 0)
switch (this.command) {
case 'goto':
return this.writeExec(buffer, this.data.fade ? Command.TIMED : Command.GO)
case 'pause':
return this.writeExec(buffer, Command.STOP)
case 'resume':
return this.writeExec(buffer, Command.RESUME)
case 'fader':
return this.writeFader(buffer)
case 'fire':
return this.writeFire(buffer)
case 'off':
return this.writeExec(buffer, Command.OFF)
default:
throw new Error('Invalid or unsupported command: ' + this.command)
}
}
}
/**
* MIDI Show Control over Ethernet Receiver
* @extends external:EventEmitter
* @emits module:mamsc~Receiver#event:error
* @emits module:mamsc~Receiver#event:ready
* @emits module:mamsc~Receiver#event:message
* @emits module:mamsc~Receiver#event:goto
* @emits module:mamsc~Receiver#event:pause
* @emits module:mamsc~Receiver#event:resume
* @emits module:mamsc~Receiver#event:fader
* @emits module:mamsc~Receiver#event:off
* @hideconstructor
*/
class Receiver extends EventEmitter {
constructor (port, address, type) {
super()
/**
* @type {Object}
* @property {Number} [deviceId=1] - Set this to a value between `0` and
* `111` to only listen for messages received for this device ID. We'll
* still react on messages send to everyone.
* @property {Number} [groupId=1] - Set this to a value between `1` and `15`
* to only listen for messages received for this group ID. We'll still
* react on messages send to everyone.
*/
this.config = {
deviceId: 1,
groupId: 1
}
const socket = dgram.createSocket({
type: type || 'udp4',
reuseAddr: true
}).on('error', err => {
this.emit('error', err)
}).on('listening', () => {
socket.setBroadcast(true)
this.emit('ready')
}).on('message', buffer => {
try {
const msg = Message.fromBuffer(buffer)
if (msg.checkTarget(this.config)) {
(({ command, data }) => {
this.emit('message', command, data)
this.emit(command, ...Object.values(data))
})(msg)
}
} catch (err) {
this.emit('error', err)
}
}).bind(port, address)
socketPool.set(this, socket)
}
/**
* Close the underlying socket and stop listening for incoming messages.
* @returns {module:mamsc~Receiver}
*/
close () {
try {
socketPool.get(this).close()
} catch (err) {
this.emit('error', err)
} finally {
socketPool.delete(this)
}
return this
}
}
/**
* Emitted whenever an error accurs within the receiver. This could be either
* socket errors or errors from the protocol parser.
* @event module:mamsc~Receiver#error
* @param {external:Error} err - The error which occurred
*/
/**
* Emitted when the underlying socket is created and the receiver starts
* listening for incomming messages.
* @event module:mamsc~Receiver#ready
*/
/**
* This is a general message event if you want to listen for all commands.
* @event module:mamsc~Receiver#message
* @param {String} command - The type of message received
* @param {Object} data - Depends on the message type. See the individual events
* for details
* @example
* // Create a new receiver and list for MSC on port 6001
* const msc = require('mamsc').in(6001)
*
* // Listen for all but the error events and log them to the console
* msc.on('message', (command, data) => { console.log(command, data) })
*/
/**
* Emitted if a Goto command is executed.
* @event module:mamsc~Receiver#goto
* @param {String} cue - Cue Number
* @param {String} exec - Executor Number
* @param {Number} [fade] - Optional fade time
*/
/**
* Emitted if a cue is paused.
* @event module:mamsc~Receiver#pause
* @param {String} exec - Executor Number
*/
/**
* Emitted if a paused cue is continued.
* @event module:mamsc~Receiver#resume
* @param {String} exec - Executor Number
*/
/**
* Emitted if a fader changed its position. The console only transmits the
* position of some faders.
* @event module:mamsc~Receiver#fader
* @param {Object} position
* @param {Number} position.value - Position value `[0..128²-1]`
* @param {Number} position.percent - Position of the fader as percentage
* @param {String} exec - Executor Number
* @param {Number} [fade] - Optional fade time
*/
/**
* Emitted if a executor is switched off.
* @event module:mamsc~Receiver#off
* @param {String} exec - Executor Number
*/
/**
* MIDI Show Control over Ethernet Transmitter
* @extends external:EventEmitter
* @emits module:mamsc~Transmitter#event:error
* @emits module:mamsc~Transmitter#event:ready
* @hideconstructor
*/
class Transmitter extends EventEmitter {
constructor (port, address, type) {
super()
/**
* @type {Object}
* @property {Number} [deviceId=1] - Set this to a value between `0` and
* `111` to restrict messages to a device and set sendTo to `'device'`
* @property {Number} [groupId=1] - Set this to a value between `1` and `15`
* to restrict messages to a group and set sendTo to `'group'`
* @property {String} [sendTo='all'] - If you want to restrict who should
* react on messages send you can set this to either `'device'` and set the
* deviceId or `'group'` and set the groupId accordingly. By default it is
* set to `'all'` so everyone will react on messages.
*/
this.config = {
deviceId: 1,
groupId: 1,
sendTo: 'all'
}
const target = address || '255.255.255.255'
const socket = dgram.createSocket({
type: type || 'udp4',
reuseAddr: true
}).on('error', err => {
this.emit('error', err)
}).on('listening', () => {
socket.setBroadcast(target.endsWith('255'))
this.emit('ready')
}).bind().unref()
socket.sendBuffer = buffer => (
socket.send(buffer, 0, buffer.length, port, target)
)
socket.sendMessage = (command, data) => (
socket.sendBuffer(Message.toBuffer(command, data, this.config))
)
socketPool.set(this, socket)
}
/**
* Sends one of the defined commands by name. The command name is identical
* to the function name. Lookup the parameters for the command at each
* function definition.
* @param {String} command - Command to be send
* @param {Object} data - Parameters for the command
* @returns {module:mamsc~Transmitter}
* @throws {external:Error}
* @example
* // Create a new transmitter and brodcast MSC to port 6100
* const msc = require('mamsc').out(6100)
*
* // Set fader position of executor 12 on page 1 to 42%
* msc.send('fader', { position: 42, exec: 12 })
*
* // Goto cue Number 8.100 on the default executor with a fade time of 5 seconds
* msc.send('goto', { cue: 8.100, fade: 5 })
*/
send (command, data) {
socketPool.get(this).sendMessage(command, data || {})
return this
}
/**
* Goto a specific cue. If you don't define an executor then the main
* executor is assumed. An optional fade time can be defined in seconds.
* @param {String} cue - Cue Number
* @param {String} [exec=main] - Executor Number
* @param {Number} [fade=no fade] - Optional fade time in seconds
* @returns {module:mamsc~Transmitter}
* @throws {external:Error}
* @example
* // Create a new transmitter and send MSC to 10.6.7.2 port 6008
* const msc = require('mamsc').out(6008, '10.6.7.2')
*
* // Goto cue Number 4.000 on executor 7, page 1
* msc.goto(4.000, 7.1)
*/
goto (cue, exec, fade) {
return this.send('goto', { cue, exec, fade })
}
/**
* Pause an executor. If you don't define an executor then the main
* executor is assumed.
* @param {String} [exec=main] - Executor Number
* @returns {module:mamsc~Transmitter}
* @throws {external:Error}
*/
pause (exec) {
return this.send('pause', { exec })
}
/**
* Resume an executor. If you don't define an executor then the main
* executor is assumed.
* @param {String} [exec=main] - Executor Number
* @returns {module:mamsc~Transmitter}
* @throws {external:Error}
*/
resume (exec) {
return this.send('resume', { exec })
}
/**
* Move a fader to a specific position. You can either set it by percentage
* or using a value between `0` and `128²-1`. If you pass a Number, percentage
* is used. If you don't define an executor then the main executor is
* assumed. An optional fade time can be defined in seconds.
* @param {(Number|Object)} position - Pass a Number to set the position by
* percentage or an Object with one of the following properties:
* @param {Number} position.percent - Position of the fader as percentage
* @param {Number} position.value - Position of the fader using a value
* between `0` and `128²-1`
* @param {String} [exec=main] - Executor Number
* @param {Number} [fade=no fade] - Optional fade time
* @returns {module:mamsc~Transmitter}
* @throws {external:Error}
* @example
* // Create a new transmitter and bordcast MSC to port 6004 on network 10.6.7.x
* const msc = require('mamsc').out(6004, '10.6.7.255')
*
* // Set fader position of executor 3 on page 1 to 50%
* msc.fader(50, 3.1)
*
* // Set fader position of executor 8 on page 1 to 50% with a fade time of 10 seconds
* msc.fader({ value: 0x1fff }, 8, 10)
*/
fader (position, exec, fade) {
return this.send('fader', { position, exec, fade })
}
/**
* Fire a macro.
* @param {Number} - Macro number between `1` and `255`.
* @returns {module:mamsc~Transmitter}
* @throws {external:Error}
*/
fire (macro) {
return this.send('fire', { macro })
}
/**
* Switch an executor off. If you don't define an executor then the main
* executor is assumed.
* @param {String} [exec=main] - Executor Number
* @returns {module:mamsc~Transmitter}
* @throws {external:Error}
*/
off (exec) {
return this.send('off', { exec })
}
/**
* Close the underlying socket.
* After closing the socket no more messages can be send.
* @returns {module:mamsc~Transmitter}
*/
close () {
try {
socketPool.get(this).close()
} catch (err) {
this.emit('error', err)
} finally {
socketPool.delete(this)
}
return this
}
}
/**
* Emitted whenever an error accurs within the transmitter. This is usually a
* socket error. User input errors are thrown and not emitted.
* @event module:mamsc~Transmitter#error
* @param {external:Error} err - The error which occurred
*/
/**
* Emitted when the underlying socket is created and the transmitter is ready
* to send messages.
* @event module:mamsc~Transmitter#ready
*/
/**
* Create a new MIDI Show Control over Ethernet Receiver.
* The port needs to be between `6000` and `6100` as per the documentation. If
* no address is defined, the socket is bound to all interfaces.
* @param {Number} port - Port to listen for incoming messages on
* (needs to be between `6000` and `6100`)
* @param {String} [address='0.0.0.0'] - Address to listen for incoming
* messages on
* @param {String} [type='udp4'] The socket family: Either `'udp4'` or `'udp6'`
* @returns {module:mamsc~Receiver}
* @example
* // Create a new receiver and receive MSC on port 6004 on all interfaces
* const receiver = require('mamsc').in(6004)
*/
module.exports.in = (port, address, type) => (new Receiver(port, address, type))
/**
* Create a new MIDI Show Control over Ethernet Transmitter.
* The port needs to be between `6000` and `6100` as per the documentation. If
* no address is defined, we brodcast to the local network.
* @param {Number} port - Destination port (needs to be between `6000` and `6100`)
* @param {String} [address='255.255.255.255'] - Destination hostname or IP address
* @param {String} [type='udp4'] The socket family: Either `'udp4'` or `'udp6'`
* @returns {module:mamsc~Transmitter}
* @example
* // Create a new transmitter and brodcast MSC to port 6005 on the local network
* const transmitter = require('mamsc').out(6005)
*/
module.exports.out = (port, address, type) => (new Transmitter(port, address, type))
/**
* @external EventEmitter
* @see http://nodejs.org/api/events.html
*/
/**
* @external Error
* @see https://nodejs.org/api/errors.html
*/