From ae4df127ad35588efea25ee7e38357e6853ade40 Mon Sep 17 00:00:00 2001 From: Luciano Iam Date: Fri, 29 May 2020 11:37:34 +0200 Subject: [PATCH] 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. --- libs/surfaces/websockets/dispatcher.cc | 10 +- .../builtin/mixer-demo/js/main.js | 137 ++++++++------- .../builtin/mixer-demo/manifest.xml | 2 +- share/web_surfaces/builtin/transport/main.js | 23 ++- .../builtin/transport/manifest.xml | 2 +- share/web_surfaces/shared/ardour.js | 164 ++++++++++-------- .../web_surfaces/shared/{ => base}/channel.js | 29 +++- share/web_surfaces/shared/base/component.js | 93 ++++++++++ share/web_surfaces/shared/base/observable.js | 64 +++++++ .../shared/{message.js => base/protocol.js} | 2 +- share/web_surfaces/shared/callback.js | 56 ------ share/web_surfaces/shared/components/mixer.js | 87 ++++++++++ .../shared/components/parameter.js | 95 ++++++++++ .../web_surfaces/shared/components/plugin.js | 72 ++++++++ share/web_surfaces/shared/components/strip.js | 116 +++++++++++++ .../shared/components/transport.js | 81 +++++++++ share/web_surfaces/shared/control.js | 89 ---------- share/web_surfaces/shared/metadata.js | 51 ------ 18 files changed, 812 insertions(+), 361 deletions(-) rename share/web_surfaces/shared/{ => base}/channel.js (65%) create mode 100644 share/web_surfaces/shared/base/component.js create mode 100644 share/web_surfaces/shared/base/observable.js rename share/web_surfaces/shared/{message.js => base/protocol.js} (98%) delete mode 100644 share/web_surfaces/shared/callback.js create mode 100644 share/web_surfaces/shared/components/mixer.js create mode 100644 share/web_surfaces/shared/components/parameter.js create mode 100644 share/web_surfaces/shared/components/plugin.js create mode 100644 share/web_surfaces/shared/components/strip.js create mode 100644 share/web_surfaces/shared/components/transport.js delete mode 100644 share/web_surfaces/shared/control.js delete mode 100644 share/web_surfaces/shared/metadata.js diff --git a/libs/surfaces/websockets/dispatcher.cc b/libs/surfaces/websockets/dispatcher.cc index 11623d30bd..82094e5505 100644 --- a/libs/surfaces/websockets/dispatcher.cc +++ b/libs/surfaces/websockets/dispatcher.cc @@ -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 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 diff --git a/share/web_surfaces/builtin/mixer-demo/js/main.js b/share/web_surfaces/builtin/mixer-demo/js/main.js index 787e2ec74b..f269857310 100644 --- a/share/web_surfaces/builtin/mixer-demo/js/main.js +++ b/share/web_surfaces/builtin/mixer-demo/js/main.js @@ -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(`
`, strips); - createElem(``, div); + const div = createElem(`
`, parentDiv); + createElem(``, 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); createElem(``, 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); createElem(``, 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(`
`, strip); + const div = createElem(`
`, parentDiv); createElem(``, 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(`
`, plugin); - createElem(``, div); + const div = createElem(`
`, parentDiv); + createElem(``, 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(); + })(); diff --git a/share/web_surfaces/builtin/mixer-demo/manifest.xml b/share/web_surfaces/builtin/mixer-demo/manifest.xml index bfa80a21b9..b394006db8 100644 --- a/share/web_surfaces/builtin/mixer-demo/manifest.xml +++ b/share/web_surfaces/builtin/mixer-demo/manifest.xml @@ -2,5 +2,5 @@ - + diff --git a/share/web_surfaces/builtin/transport/main.js b/share/web_surfaces/builtin/transport/main.js index a4e36a6d0c..756d8a5a39 100644 --- a/share/web_surfaces/builtin/transport/main.js +++ b/share/web_surfaces/builtin/transport/main.js @@ -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); diff --git a/share/web_surfaces/builtin/transport/manifest.xml b/share/web_surfaces/builtin/transport/manifest.xml index 1d8c664f10..be9936e4c0 100644 --- a/share/web_surfaces/builtin/transport/manifest.xml +++ b/share/web_surfaces/builtin/transport/manifest.xml @@ -2,5 +2,5 @@ - + diff --git a/share/web_surfaces/shared/ardour.js b/share/web_surfaces/shared/ardour.js index 7a7fb8bb30..68a0b3e6ab 100644 --- a/share/web_surfaces/shared/ardour.js +++ b/share/web_surfaces/shared/ardour.js @@ -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; } diff --git a/share/web_surfaces/shared/channel.js b/share/web_surfaces/shared/base/channel.js similarity index 65% rename from share/web_surfaces/shared/channel.js rename to share/web_surfaces/shared/base/channel.js index bc5ce704c5..df95786a43 100644 --- a/share/web_surfaces/shared/channel.js +++ b/share/web_surfaces/shared/base/channel.js @@ -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) {} } diff --git a/share/web_surfaces/shared/base/component.js b/share/web_surfaces/shared/base/component.js new file mode 100644 index 0000000000..c6dfc46619 --- /dev/null +++ b/share/web_surfaces/shared/base/component.js @@ -0,0 +1,93 @@ +/* + * Copyright © 2020 Luciano Iam + * + * 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); + } + +} diff --git a/share/web_surfaces/shared/base/observable.js b/share/web_surfaces/shared/base/observable.js new file mode 100644 index 0000000000..bd2779018f --- /dev/null +++ b/share/web_surfaces/shared/base/observable.js @@ -0,0 +1,64 @@ +/* + * Copyright © 2020 Luciano Iam + * + * 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); + } + } + } + +} diff --git a/share/web_surfaces/shared/message.js b/share/web_surfaces/shared/base/protocol.js similarity index 98% rename from share/web_surfaces/shared/message.js rename to share/web_surfaces/shared/base/protocol.js index 6bdf98e221..721962b6d2 100644 --- a/share/web_surfaces/shared/message.js +++ b/share/web_surfaces/shared/base/protocol.js @@ -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', diff --git a/share/web_surfaces/shared/callback.js b/share/web_surfaces/shared/callback.js deleted file mode 100644 index 0a8159b50e..0000000000 --- a/share/web_surfaces/shared/callback.js +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright © 2020 Luciano Iam - * - * 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) {} - -} diff --git a/share/web_surfaces/shared/components/mixer.js b/share/web_surfaces/shared/components/mixer.js new file mode 100644 index 0000000000..48105759b6 --- /dev/null +++ b/share/web_surfaces/shared/components/mixer.js @@ -0,0 +1,87 @@ +/* + * Copyright © 2020 Luciano Iam + * + * 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; + } + +} diff --git a/share/web_surfaces/shared/components/parameter.js b/share/web_surfaces/shared/components/parameter.js new file mode 100644 index 0000000000..9053804a63 --- /dev/null +++ b/share/web_surfaces/shared/components/parameter.js @@ -0,0 +1,95 @@ +/* + * Copyright © 2020 Luciano Iam + * + * 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; + } + +} diff --git a/share/web_surfaces/shared/components/plugin.js b/share/web_surfaces/shared/components/plugin.js new file mode 100644 index 0000000000..fa9ae5e813 --- /dev/null +++ b/share/web_surfaces/shared/components/plugin.js @@ -0,0 +1,72 @@ +/* + * Copyright © 2020 Luciano Iam + * + * 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; + } + +} diff --git a/share/web_surfaces/shared/components/strip.js b/share/web_surfaces/shared/components/strip.js new file mode 100644 index 0000000000..69ba3a8ad7 --- /dev/null +++ b/share/web_surfaces/shared/components/strip.js @@ -0,0 +1,116 @@ +/* + * Copyright © 2020 Luciano Iam + * + * 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; + } + +} diff --git a/share/web_surfaces/shared/components/transport.js b/share/web_surfaces/shared/components/transport.js new file mode 100644 index 0000000000..16f18e312b --- /dev/null +++ b/share/web_surfaces/shared/components/transport.js @@ -0,0 +1,81 @@ +/* + * Copyright © 2020 Luciano Iam + * + * 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; + } + +} diff --git a/share/web_surfaces/shared/control.js b/share/web_surfaces/shared/control.js deleted file mode 100644 index 13dbdfa695..0000000000 --- a/share/web_surfaces/shared/control.js +++ /dev/null @@ -1,89 +0,0 @@ -/* - * Copyright © 2020 Luciano Iam - * - * 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]); - } - -} diff --git a/share/web_surfaces/shared/metadata.js b/share/web_surfaces/shared/metadata.js deleted file mode 100644 index 888476c681..0000000000 --- a/share/web_surfaces/shared/metadata.js +++ /dev/null @@ -1,51 +0,0 @@ -/* - * Copyright © 2020 Luciano Iam - * - * 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); - } - } - -}