WebSockets: implement a JavaScript object-oriented client API

Replace previous callback based basic client with an easier
to use object-oriented API that further abstracts the low level
details of the WebSockets Server surface messaging protocol.

All built-in web surface demos were updated to use the new API.
This commit is contained in:
Luciano Iam 2020-05-29 11:37:34 +02:00 committed by Robin Gareus
parent 5296ed141f
commit ae4df127ad
Signed by: rgareus
GPG Key ID: A090BCE02CF57F04
18 changed files with 812 additions and 361 deletions

View File

@ -55,11 +55,6 @@ WebsocketsDispatcher::dispatch (Client client, const NodeStateMessage& msg)
void
WebsocketsDispatcher::update_all_nodes (Client client)
{
update (client, Node::tempo, globals ().tempo ());
update (client, Node::position_time, globals ().position_time ());
update (client, Node::transport_roll, globals ().transport_roll ());
update (client, Node::record_state, globals ().record_state ());
for (uint32_t strip_n = 0; strip_n < strips ().strip_count (); ++strip_n) {
boost::shared_ptr<Stripable> strip = strips ().nth_strip (strip_n);
@ -140,6 +135,11 @@ WebsocketsDispatcher::update_all_nodes (Client client)
}
}
}
update (client, Node::tempo, globals ().tempo ());
update (client, Node::position_time, globals ().position_time ());
update (client, Node::transport_roll, globals ().transport_roll ());
update (client, Node::record_state, globals ().record_state ());
}
void

View File

@ -16,10 +16,6 @@
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
// This example does not call the API methods in control.js,
// instead it couples the widgets directly to the message stream
import { ANode, Message } from '/shared/message.js';
import { ArdourClient } from '/shared/ardour.js';
import { Switch, DiscreteSlider, ContinuousSlider, LogarithmicSlider,
@ -29,108 +25,127 @@ import { Switch, DiscreteSlider, ContinuousSlider, LogarithmicSlider,
const MAX_LOG_LINES = 1000;
const ardour = new ArdourClient(location.host);
const widgets = {};
main();
const ardour = new ArdourClient();
function main () {
ardour.handlers = {
onConnected: (connected) => {
if (connected) {
log('Client connected', 'info');
} else {
log('Client disconnected', 'error');
}
},
onMessage: (message, inbound) => {
if (inbound) {
log(`${message}`, 'message-in');
} else {
log(`${message}`, 'message-out');
}
}
};
ardour.getSurfaceManifest().then((manifest) => {
const div = document.getElementById('manifest');
div.innerHTML = `${manifest.name.toUpperCase()} v${manifest.version}${manifest.description}`;
div.innerHTML = manifest.name.toUpperCase()
+ ' v' + manifest.version + ' — ' + manifest.description;
});
ardour.addCallbacks({
onConnected: (error) => { log('Client connected', 'info'); },
onDisconnected: (error) => { log('Client disconnected', 'error'); },
onMessage: processMessage,
onStripDescription: createStrip,
onStripPluginDescription: createStripPlugin,
onStripPluginParamDescription: createStripPluginParam
ardour.mixer.on('ready', () => {
const div = document.getElementById('strips');
for (const strip of ardour.mixer.strips) {
createStrip(strip, div);
}
});
ardour.connect();
}
function createStrip (stripId, name, isVca) {
const domId = `strip-${stripId}`;
function createStrip (strip, parentDiv) {
const domId = `strip-${strip.addrId}`;
if (document.getElementById(domId) != null) {
return;
}
const strips = document.getElementById('strips');
const div = createElem(`<div class="strip" id="${domId}"></div>`, strips);
createElem(`<label class="comp-name" for="${domId}">∿&emsp;&emsp;${name}</label>`, div);
const div = createElem(`<div class="strip" id="${domId}"></div>`, parentDiv);
createElem(`<label class="comp-name" for="${domId}">∿&emsp;&emsp;${strip.name}</label>`, div);
// meter
const meter = new StripMeter();
meter.el.classList.add('slider-meter');
meter.appendTo(div);
connectWidget(meter, ANode.STRIP_METER, stripId);
bind(strip, 'meter', meter);
// gain
let holder = createElem(`<div class="strip-slider"></div>`, div);
createElem(`<label>Gain</label>`, holder);
const gain = new StripGainSlider();
gain.appendTo(holder);
connectWidget(gain, ANode.STRIP_GAIN, stripId);
bind(strip, 'gain', gain);
if (!isVca) {
if (!strip.isVca) {
// pan
holder = createElem(`<div class="strip-slider"></div>`, div);
createElem(`<label>Pan</label>`, holder);
const pan = new StripPanSlider();
pan.appendTo(holder);
connectWidget(pan, ANode.STRIP_PAN, stripId);
bind(strip, 'pan', pan);
}
for (const plugin of strip.plugins) {
createStripPlugin(plugin, div);
}
}
function createStripPlugin (stripId, pluginId, name) {
const domId = `plugin-${stripId}-${pluginId}`;
function createStripPlugin (plugin, parentDiv) {
const domId = `plugin-${plugin.addrId}`;
if (document.getElementById(domId) != null) {
return;
}
const strip = document.getElementById(`strip-${stripId}`);
const div = createElem(`<div class="plugin" id="${domId}"></div>`, strip);
const div = createElem(`<div class="plugin" id="${domId}"></div>`, parentDiv);
createElem(`<label class="comp-name">⨍&emsp;&emsp;${name}</label>`, div);
const enable = new Switch();
enable.el.classList.add('plugin-enable');
enable.appendTo(div);
connectWidget(enable, ANode.STRIP_PLUGIN_ENABLE, stripId, pluginId);
bind(plugin, 'enable', enable);
for (const param of plugin.parameters) {
createStripPluginParam(param, div);
}
}
function createStripPluginParam (stripId, pluginId, paramId, name, valueType, min, max, isLog) {
const domId = `param-${stripId}-${pluginId}-${paramId}`;
function createStripPluginParam (param, parentDiv) {
const domId = `param-${param.addrId}`;
if (document.getElementById(domId) != null) {
return;
}
let param, cssClass;
let widget, cssClass;
if (valueType == 'b') {
if (param.valueType.isBoolean) {
cssClass = 'boolean';
param = new Switch();
} else if (valueType == 'i') {
widget = new Switch();
} else if (param.valueType.isInteger) {
cssClass = 'discrete';
param = new DiscreteSlider(min, max);
} else if (valueType == 'd') {
widget = new DiscreteSlider(param.min, param.max);
} else if (param.valueType.isDouble) {
cssClass = 'continuous';
if (isLog) {
param = new LogarithmicSlider(min, max);
if (param.isLog) {
widget = new LogarithmicSlider(param.min, param.max);
} else {
param = new ContinuousSlider(min, max);
widget = new ContinuousSlider(param.min, param.max);
}
}
const plugin = document.getElementById(`plugin-${stripId}-${pluginId}`);
const div = createElem(`<div class="plugin-param ${cssClass}" id="${domId}"></div>`, plugin);
createElem(`<label for="${domId}">${name}</label>`, div);
const div = createElem(`<div class="plugin-param ${cssClass}" id="${domId}"></div>`, parentDiv);
createElem(`<label for="${domId}">${param.name}</label>`, div);
param.el.name = domId;
param.appendTo(div);
connectWidget(param, ANode.STRIP_PLUGIN_PARAM_VALUE, stripId, pluginId, paramId);
widget.el.name = domId;
widget.appendTo(div);
bind(param, 'value', widget);
}
function createElem (html, parent) {
@ -146,24 +161,12 @@ import { Switch, DiscreteSlider, ContinuousSlider, LogarithmicSlider,
return elem;
}
function connectWidget (widget, node, ...addr) {
const nodeAddrId = Message.nodeAddrId(node, addr);
widgets[nodeAddrId] = widget;
widget.callback = (val) => {
const msg = new Message(node, addr, [val]);
log(`${msg}`, 'message-out');
ardour.send(msg);
};
}
function processMessage (msg) {
log(`${msg}`, 'message-in');
if (widgets[msg.nodeAddrId]) {
widgets[msg.nodeAddrId].value = msg.val[0];
}
function bind (component, property, widget) {
// ardour → ui
widget.value = component[property];
component.on(property, (value) => widget.value = value);
// ui → ardour
widget.callback = (value) => component[property] = value;
}
function log (message, className) {
@ -181,4 +184,6 @@ import { Switch, DiscreteSlider, ContinuousSlider, LogarithmicSlider,
output.scrollTop = output.scrollHeight;
}
main();
})();

View File

@ -2,5 +2,5 @@
<WebSurface>
<Name value="Mixer Demo"/>
<Description value="Mixer control capabilities demo aimed at developers"/>
<Version value="0.1.0"/>
<Version value="0.1.1"/>
</WebSurface>

View File

@ -21,11 +21,11 @@ import { ArdourClient } from '/shared/ardour.js';
(() => {
const dom = {
main: document.getElementById('main'),
time: document.getElementById('time'),
roll: document.getElementById('roll'),
record: document.getElementById('record'),
fullscreen: document.getElementById('fullscreen')
main : document.getElementById('main'),
time : document.getElementById('time'),
roll : document.getElementById('roll'),
record : document.getElementById('record'),
fullscreen : document.getElementById('fullscreen')
};
const ardour = new ArdourClient();
@ -36,12 +36,9 @@ import { ArdourClient } from '/shared/ardour.js';
function main () {
addDomEventListeners();
ardour.addCallbacks({
onError: console.log,
onPositionTime: setPosition,
onTransportRoll: setRolling,
onRecordState: setRecord
});
ardour.transport.on('time', setPosition);
ardour.transport.on('roll', setRolling);
ardour.transport.on('record', setRecord);
ardour.connect();
}
@ -52,14 +49,14 @@ import { ArdourClient } from '/shared/ardour.js';
const roll = () => {
setRolling(!_rolling);
ardour.setTransportRoll(_rolling);
ardour.transport.roll = _rolling;
};
dom.roll.addEventListener(touchOrClick, roll);
const record = () => {
setRecord(!_record);
ardour.setRecordState(_record);
ardour.transport.record = _record;
};
dom.record.addEventListener(touchOrClick, record);

View File

@ -2,5 +2,5 @@
<WebSurface>
<Name value="Transport"/>
<Description value="Provides basic transport control"/>
<Version value="0.1.0"/>
<Version value="0.1.1"/>
</WebSurface>

View File

@ -16,40 +16,54 @@
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { ControlMixin } from './control.js';
import { MetadataMixin } from './metadata.js';
import { Message } from './message.js';
import { MessageChannel } from './channel.js';
import { MessageChannel } from './base/channel.js';
import { StateNode } from './base/protocol.js';
import { Mixer } from './components/mixer.js';
import { Transport } from './components/transport.js';
// See ControlMixin and MetadataMixin for available APIs
// See ArdourCallback for an example callback implementation
export class ArdourClient {
class BaseArdourClient {
constructor (host) {
this._callbacks = [];
constructor (handlers, options) {
this._options = options || {};
this._components = [];
this._connected = false;
this._pendingRequest = null;
this._channel = new MessageChannel(host || location.host);
this._channel.onError = (error) => {
this._fireCallbacks('error', error);
this._channel = new MessageChannel(this._options['host'] || location.host);
this._channel.onMessage = (msg, inbound) => {
this._handleMessage(msg, inbound);
};
this._channel.onMessage = (msg) => {
this._onChannelMessage(msg);
};
if (!('components' in this._options) || this._options['components']) {
this._mixer = new Mixer(this._channel);
this._transport = new Transport(this._channel);
this._components.push(this._mixer, this._transport);
}
this.handlers = handlers;
}
addCallbacks (callbacks) {
this._callbacks.push(callbacks);
set handlers (handlers) {
this._handlers = handlers || {};
this._channel.onError = this._handlers['onError'] || console.log;
}
// Access to the object-oriented API (enabled by default)
get mixer () {
return this._mixer;
}
get transport () {
return this._transport;
}
// Low level control messages flow through a WebSocket
async connect (autoReconnect) {
this._channel.onClose = async () => {
if (this._connected) {
this._fireCallbacks('disconnected');
this._connected = false;
this._setConnected(false);
}
if ((autoReconnect == null) || autoReconnect) {
@ -71,50 +85,69 @@ class BaseArdourClient {
this._channel.send(msg);
}
// Private methods
async _connect () {
await this._channel.open();
this._connected = true;
this._fireCallbacks('connected');
async sendAndReceive (msg) {
return await this._channel.sendAndReceive(msg);
}
_send (node, addr, val) {
const msg = new Message(node, addr, val);
this.send(msg);
return msg;
}
// Surface metadata API goes over HTTP
async _sendAndReceive (node, addr, val) {
return new Promise((resolve, reject) => {
const nodeAddrId = this._send(node, addr, val).nodeAddrId;
this._pendingRequest = {resolve: resolve, nodeAddrId: nodeAddrId};
});
}
async _sendRecvSingle (node, addr, val) {
return (await this._sendAndReceive (node, addr, val))[0];
}
_onChannelMessage (msg) {
if (this._pendingRequest && (this._pendingRequest.nodeAddrId == msg.nodeAddrId)) {
this._pendingRequest.resolve(msg.val);
this._pendingRequest = null;
async getAvailableSurfaces () {
const response = await fetch('/surfaces.json');
if (response.status == 200) {
return await response.json();
} else {
this._fireCallbacks('message', msg);
this._fireCallbacks(msg.node, ...msg.addr, ...msg.val);
throw this._fetchResponseStatusError(response.status);
}
}
_fireCallbacks (name, ...args) {
// name_with_underscores -> onNameWithUnderscores
const method = 'on' + name.split('_').map((s) => {
return s[0].toUpperCase() + s.slice(1).toLowerCase();
}).join('');
async getSurfaceManifest () {
const response = await fetch('manifest.xml');
for (const callbacks of this._callbacks) {
if (method in callbacks) {
callbacks[method](...args)
if (response.status == 200) {
const manifest = {};
const xmlText = await response.text();
const xmlDoc = new DOMParser().parseFromString(xmlText, 'text/xml');
for (const child of xmlDoc.children[0].children) {
manifest[child.tagName.toLowerCase()] = child.getAttribute('value');
}
return manifest;
} else {
throw this._fetchResponseStatusError(response.status);
}
}
// Private methods
async _sleep (t) {
return new Promise(resolve => setTimeout(resolve, t));
}
async _connect () {
await this._channel.open();
this._setConnected(true);
}
_setConnected (connected) {
this._connected = connected;
if (this._handlers['onConnected']) {
this._handlers['onConnected'](this._connected);
}
}
_handleMessage (msg, inbound) {
if (this._handlers['onMessage']) {
this._handlers['onMessage'](msg, inbound);
}
if (inbound) {
for (const component of this._components) {
if (component.handleMessage(msg)) {
break;
}
}
}
}
@ -123,21 +156,4 @@ class BaseArdourClient {
return new Error(`HTTP response status ${status}`);
}
async _sleep (t) {
return new Promise(resolve => setTimeout(resolve, 1000));
}
}
export class ArdourClient extends mixin(BaseArdourClient, ControlMixin, MetadataMixin) {}
function mixin (dstClass, ...classes) {
for (const srcClass of classes) {
for (const propName of Object.getOwnPropertyNames(srcClass.prototype)) {
if (propName != 'constructor') {
dstClass.prototype[propName] = srcClass.prototype[propName];
}
}
}
return dstClass;
}

View File

@ -16,13 +16,14 @@
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { ANode, Message } from './message.js';
import { Message } from './protocol.js';
export class MessageChannel {
constructor (host) {
// https://developer.mozilla.org/en-US/docs/Web/API/URL/host
this._host = host;
this._pending = null;
}
async open () {
@ -34,7 +35,14 @@ export class MessageChannel {
this._socket.onerror = (error) => this.onError(error);
this._socket.onmessage = (event) => {
this.onMessage (Message.fromJsonText(event.data));
const msg = Message.fromJsonText(event.data);
if (this._pending && (this._pending.nodeAddrId == msg.nodeAddrId)) {
this._pending.resolve(msg);
this._pending = null;
} else {
this.onMessage(msg, true);
}
};
this._socket.onopen = resolve;
@ -43,18 +51,31 @@ export class MessageChannel {
close () {
this._socket.close();
if (this._pending) {
this._pending.reject(Error('MessageChannel: socket closed awaiting response'));
this._pending = null;
}
}
send (msg) {
if (this._socket) {
this._socket.send(msg.toJsonText());
this.onMessage(msg, false);
} else {
throw Error('MessageChannel: cannot call send() before open()');
this.onError(Error('MessageChannel: cannot call send() before open()'));
}
}
async sendAndReceive (msg) {
return new Promise((resolve, reject) => {
this._pending = {resolve: resolve, reject: reject, nodeAddrId: msg.nodeAddrId};
this.send(msg);
});
}
onClose () {}
onError (error) {}
onMessage (msg) {}
onMessage (msg, inbound) {}
}

View File

@ -0,0 +1,93 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { Message } from './protocol.js';
import { Observable } from './observable.js';
export class Component extends Observable {
constructor (parent) {
super();
this._parent = parent;
}
get channel () {
return this._parent.channel;
}
on (property, callback) {
this.addObserver(property, (self) => callback(self[property]));
}
send (node, addr, val) {
this.channel.send(new Message(node, addr, val));
}
handle (node, addr, val) {
return false;
}
handleMessage (msg) {
return this.handle(msg.node, msg.addr, msg.val);
}
updateLocal (property, value) {
this['_' + property] = value;
this.notifyObservers(property);
}
updateRemote (property, value, node, addr) {
this['_' + property] = value;
this.send(node, addr || [], [value]);
}
}
export class RootComponent extends Component {
constructor (channel) {
super(null);
this._channel = channel;
}
get channel () {
return this._channel;
}
}
export class AddressableComponent extends Component {
constructor (parent, addr) {
super(parent);
this._addr = addr;
}
get addr () {
return this._addr;
}
get addrId () {
return this._addr.join('-');
}
updateRemote (property, value, node) {
super.updateRemote(property, value, node, this.addr);
}
}

View File

@ -0,0 +1,64 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
export class Observable {
constructor () {
this._observers = {};
}
addObserver (property, observer) {
// property=undefined means the caller is interested in observing all properties
if (!(property in this._observers)) {
this._observers[property] = [];
}
this._observers[property].push(observer);
}
removeObserver (property, observer) {
// property=undefined means the caller is not interested in any property anymore
if (typeof(property) == 'undefined') {
for (const property in this._observers) {
this.removeObserver(property, observer);
}
} else {
const index = this._observers[property].indexOf(observer);
if (index > -1) {
this._observers[property].splice(index, 1);
}
}
}
notifyObservers (property) {
// always notify observers that observe all properties
if (undefined in this._observers) {
for (const observer of this._observers[undefined]) {
observer(this, property);
}
}
if (property in this._observers) {
for (const observer of this._observers[property]) {
observer(this);
}
}
}
}

View File

@ -18,7 +18,7 @@
export const JSON_INF = 1.0e+128;
export const ANode = Object.freeze({
export const StateNode = Object.freeze({
TEMPO: 'tempo',
POSITION_TIME: 'position_time',
TRANSPORT_ROLL: 'transport_roll',

View File

@ -1,56 +0,0 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
// Example empty callback
export class ArdourCallback {
// Connection status
onConnected () {}
onDisconnected () {}
// All messages and errors
onMessage (msg) {}
onError (error) {}
// Globals
onTempo (bpm) {}
onPositionTime (seconds) {}
onTransportRoll (value) {}
onRecordState (value) {}
// Strips
onStripDescription (stripId, name, isVca) {}
onStripMeter (stripId, db) {}
onStripGain (stripId, db) {}
onStripPan (stripId, value) {}
onStripMute (stripId, value) {}
// Strip plugins
onStripPluginDescription (stripId, pluginId, name) {}
onStripPluginEnable (stripId, pluginId, value) {}
// Strip plugin parameters
// valueType
// 'b' : boolean
// 'i' : integer
// 'd' : double
onStripPluginParamDescription (stripId, pluginId, paramId, name, valueType, min, max, isLog) {}
onStripPluginParamValue (stripId, pluginId, paramId, value) {}
}

View File

@ -0,0 +1,87 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { RootComponent } from '../base/component.js';
import { StateNode } from '../base/protocol.js';
import { Strip } from './strip.js';
export class Mixer extends RootComponent {
constructor (channel) {
super(channel);
this._strips = {};
this._ready = false;
}
get ready () {
return this._ready;
}
get strips () {
return Object.values(this._strips);
}
getStripByName (name) {
name = name.trim().toLowerCase();
return this.strips.find(strip => strip.name.trim().toLowerCase() == name);
}
handle (node, addr, val) {
if (node.startsWith('strip')) {
if (node == StateNode.STRIP_DESCRIPTION) {
this._strips[addr] = new Strip(this, addr, val);
this.notifyObservers('strips');
} else {
const stripAddr = [addr[0]];
if (stripAddr in this._strips) {
this._strips[stripAddr].handle(node, addr, val);
} else {
return false;
}
}
return true;
}
/*
RECORD_STATE signals all mixer initial state has been sent because
it is the last message to arrive immediately after client connection,
see WebsocketsDispatcher::update_all_nodes() in dispatcher.cc
For this to work the mixer component needs to receive incoming
messages before the transport component, otherwise the latter would
consume RECORD_STATE.
Some ideas for a better implementation of mixer readiness detection:
- Implement message bundles like OSC to pack all initial state
updates into a single unit
- Move *_DESCRIPTION messages to single message with val={JSON data},
currently val only supports primitive data types
- Append a termination or mixer ready message in update_all_nodes(),
easiest but the least elegant
*/
if (!this._ready && (node == StateNode.RECORD_STATE)) {
this.updateLocal('ready', true);
}
return false;
}
}

View File

@ -0,0 +1,95 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { AddressableComponent } from '../base/component.js';
import { StateNode } from '../base/protocol.js';
class ValueType {
constructor (rawType) {
this._rawType = rawType;
}
get isBoolean () {
return this._rawType == 'b';
}
get isInteger () {
return this._rawType == 'i';
}
get isDouble () {
return this._rawType == 'd';
}
}
export class Parameter extends AddressableComponent {
constructor (parent, addr, desc) {
super(parent, addr);
this._name = desc[0];
this._valueType = new ValueType(desc[1]);
this._min = desc[2];
this._max = desc[3];
this._isLog = desc[4];
this._value = 0;
}
get plugin () {
return this._parent;
}
get name () {
return this._name;
}
get valueType () {
return this._valueType;
}
get min () {
return this._min;
}
get max () {
return this._max;
}
get isLog () {
return this._isLog;
}
get value () {
return this._value;
}
set value (value) {
this.updateRemote('value', value, StateNode.STRIP_PLUGIN_PARAM_VALUE);
}
handle (node, addr, val) {
if (node == StateNode.STRIP_PLUGIN_PARAM_VALUE) {
this.updateLocal('value', val[0]);
return true;
}
return false;
}
}

View File

@ -0,0 +1,72 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { AddressableComponent } from '../base/component.js';
import { Parameter } from './parameter.js';
import { StateNode } from '../base/protocol.js';
export class Plugin extends AddressableComponent {
constructor (parent, addr, desc) {
super(parent, addr);
this._parameters = {};
this._name = desc[0];
this._enable = false;
}
get strip () {
return this._parent;
}
get parameters () {
return Object.values(this._parameters);
}
get name () {
return this._name;
}
get enable () {
return this._enable;
}
set enable (value) {
this.updateRemote('enable', value, StateNode.STRIP_PLUGIN_ENABLE);
}
handle (node, addr, val) {
if (node.startsWith('strip_plugin_param')) {
if (node == StateNode.STRIP_PLUGIN_PARAM_DESCRIPTION) {
this._parameters[addr] = new Parameter(this, addr, val);
this.notifyObservers('parameters');
} else {
if (addr in this._parameters) {
this._parameters[addr].handle(node, addr, val);
}
}
return true;
} else if (node == StateNode.STRIP_PLUGIN_ENABLE) {
this.updateLocal('enable', val[0]);
return true;
}
return false;
}
}

View File

@ -0,0 +1,116 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { AddressableComponent } from '../base/component.js';
import { Plugin } from './plugin.js';
import { StateNode } from '../base/protocol.js';
export class Strip extends AddressableComponent {
constructor (parent, addr, desc) {
super(parent, addr);
this._plugins = {};
this._name = desc[0];
this._isVca = desc[1];
this._meter = 0;
this._gain = 0;
this._pan = 0;
this._mute = false;
}
get plugins () {
return Object.values(this._plugins);
}
get name () {
return this._name;
}
get isVca () {
return this._isVca;
}
get meter () {
return this._meter;
}
get gain () {
return this._gain;
}
set gain (db) {
this.updateRemote('gain', db, StateNode.STRIP_GAIN);
}
get pan () {
return this._pan;
}
set pan (value) {
this.updateRemote('pan', value, StateNode.STRIP_PAN);
}
get mute () {
return this._mute;
}
set mute (value) {
this.updateRemote('mute', value, StateNode.STRIP_MUTE);
}
handle (node, addr, val) {
if (node.startsWith('strip_plugin')) {
if (node == StateNode.STRIP_PLUGIN_DESCRIPTION) {
this._plugins[addr] = new Plugin(this, addr, val);
this.notifyObservers('plugins');
} else {
const pluginAddr = [addr[0], addr[1]];
if (pluginAddr in this._plugins) {
this._plugins[pluginAddr].handle(node, addr, val);
} else {
return false;
}
}
return true;
} else {
switch (node) {
case StateNode.STRIP_METER:
this.updateLocal('meter', val[0]);
break;
case StateNode.STRIP_GAIN:
this.updateLocal('gain', val[0]);
break;
case StateNode.STRIP_PAN:
this.updateLocal('pan', val[0]);
break;
case StateNode.STRIP_MUTE:
this.updateLocal('mute', val[0]);
break;
default:
return false;
}
return true;
}
return false;
}
}

View File

@ -0,0 +1,81 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { RootComponent } from '../base/component.js';
import { StateNode } from '../base/protocol.js';
export class Transport extends RootComponent {
constructor (channel) {
super(channel);
this._time = 0;
this._tempo = 0;
this._roll = false;
this._record = false;
}
get time () {
return this._time;
}
get tempo () {
return this._tempo;
}
set tempo (bpm) {
this.updateRemote('tempo', bpm, StateNode.TEMPO);
}
get roll () {
return this._roll;
}
set roll (value) {
this.updateRemote('roll', value, StateNode.TRANSPORT_ROLL);
}
get record () {
return this._record;
}
set record (value) {
this.updateRemote('record', value, StateNode.RECORD_STATE);
}
handle (node, addr, val) {
switch (node) {
case StateNode.TEMPO:
this.updateLocal('tempo', val[0]);
break;
case StateNode.POSITION_TIME:
this.updateLocal('time', val[0]);
break;
case StateNode.TRANSPORT_ROLL:
this.updateLocal('roll', val[0]);
break;
case StateNode.RECORD_STATE:
this.updateLocal('record', val[0]);
break;
default:
return false;
}
return true;
}
}

View File

@ -1,89 +0,0 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
import { ANode } from './message.js';
// Surface control API over WebSockets
export class ControlMixin {
async getTempo () {
return await this._sendRecvSingle(ANode.TEMPO);
}
async getTransportRoll () {
return await this._sendRecvSingle(ANode.TRANSPORT_ROLL);
}
async getRecordState () {
return await this._sendRecvSingle(ANode.RECORD_STATE);
}
async getStripGain (stripId) {
return await this._sendRecvSingle(ANode.STRIP_GAIN, [stripId]);
}
async getStripPan (stripId) {
return await this._sendRecvSingle(ANode.STRIP_PAN, [stripId]);
}
async getStripMute (stripId) {
return await this._sendRecvSingle(ANode.STRIP_MUTE, [stripId]);
}
async getStripPluginEnable (stripId, pluginId) {
return await this._sendRecvSingle(ANode.STRIP_PLUGIN_ENABLE, [stripId, pluginId]);
}
async getStripPluginParamValue (stripId, pluginId, paramId) {
return await this._sendRecvSingle(ANode.STRIP_PLUGIN_PARAM_VALUE, [stripId, pluginId, paramId]);
}
setTempo (bpm) {
this._send(ANode.TEMPO, [], [bpm]);
}
setTransportRoll (value) {
this._send(ANode.TRANSPORT_ROLL, [], [value]);
}
setRecordState (value) {
this._send(ANode.RECORD_STATE, [], [value]);
}
setStripGain (stripId, db) {
this._send(ANode.STRIP_GAIN, [stripId], [db]);
}
setStripPan (stripId, value) {
this._send(ANode.STRIP_PAN, [stripId], [value]);
}
setStripMute (stripId, value) {
this._send(ANode.STRIP_MUTE, [stripId], [value]);
}
setStripPluginEnable (stripId, pluginId, value) {
this._send(ANode.STRIP_PLUGIN_ENABLE, [stripId, pluginId], [value]);
}
setStripPluginParamValue (stripId, pluginId, paramId, value) {
this._send(ANode.STRIP_PLUGIN_PARAM_VALUE, [stripId, pluginId, paramId], [value]);
}
}

View File

@ -1,51 +0,0 @@
/*
* Copyright © 2020 Luciano Iam <lucianito@gmail.com>
*
* This program is free software; you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation; either version 2 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along
* with this program; if not, write to the Free Software Foundation, Inc.,
* 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
*/
// Surface metadata API over HTTP
export class MetadataMixin {
async getAvailableSurfaces () {
const response = await fetch('/surfaces.json');
if (response.status == 200) {
return await response.json();
} else {
throw this._fetchResponseStatusError(response.status);
}
}
async getSurfaceManifest () {
const response = await fetch('manifest.xml');
if (response.status == 200) {
const manifest = {};
const xmlText = await response.text();
const xmlDoc = new DOMParser().parseFromString(xmlText, 'text/xml');
for (const child of xmlDoc.children[0].children) {
manifest[child.tagName.toLowerCase()] = child.getAttribute('value');
}
return manifest;
} else {
throw this._fetchResponseStatusError(response.status);
}
}
}