merging-ravenna 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +100 -0
- package/index.js +14 -0
- package/package.json +39 -0
- package/src/catalog.js +137 -0
- package/src/compat.js +72 -0
- package/src/engine.js +499 -0
- package/src/paths.js +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Robbetts
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# merging-ravenna
|
|
2
|
+
|
|
3
|
+
A small, standalone Node.js client for **Merging Technologies** RAVENNA devices
|
|
4
|
+
(Hapi, Horus, Anubis) that speaks their web-UI control protocol — CometD/Bayeux
|
|
5
|
+
over WebSocket — directly. No Node-RED, no browser, no extra services.
|
|
6
|
+
|
|
7
|
+
> **Unofficial.** This library was built by observing the device's own web
|
|
8
|
+
> interface traffic. It is **not affiliated with or endorsed by Merging
|
|
9
|
+
> Technologies**, and the protocol is undocumented and may change between
|
|
10
|
+
> firmware versions. Use at your own risk; always keep a way to restore settings.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install merging-ravenna
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Quick start
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
const { RavennaEngine } = require('merging-ravenna');
|
|
22
|
+
|
|
23
|
+
const eng = new RavennaEngine({ host: '192.168.0.146' });
|
|
24
|
+
|
|
25
|
+
eng.on('online', () => console.log('device up; catalog:', eng.catalog.length, 'params'));
|
|
26
|
+
eng.on('offline', (reason) => console.log('device down:', reason));
|
|
27
|
+
eng.on('param', (p) => console.log('changed', p.moduleId, p.key, '=', p.value));
|
|
28
|
+
|
|
29
|
+
eng.connect();
|
|
30
|
+
|
|
31
|
+
// Set D/A (module 60) output gain to -20 dB. Value is dB; clamped to device range.
|
|
32
|
+
eng.setParam(60, 'attenuation', -20);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Why it is resilient
|
|
36
|
+
|
|
37
|
+
The engine handles two failure modes differently:
|
|
38
|
+
|
|
39
|
+
- **Socket drop / device powered off** → exponential-backoff reconnect
|
|
40
|
+
(`2s, 5s, 15s, 30s` capped), retrying forever.
|
|
41
|
+
- **Device wedged but socket still open** → a **data-liveness watchdog**: the
|
|
42
|
+
device emits `/ravenna/status` about every 2s, so if no frame arrives within
|
|
43
|
+
`livenessMs` (default 7s) the connection is declared stale, torn down, and
|
|
44
|
+
re-established.
|
|
45
|
+
|
|
46
|
+
`online` / `offline` are **edge-triggered** (emitted once per transition), and
|
|
47
|
+
every successful (re)connect re-runs the handshake and full state fetch — so
|
|
48
|
+
after a power-cycle (which rotates the device's `clientId`), state is always
|
|
49
|
+
re-read from the device rather than assumed.
|
|
50
|
+
|
|
51
|
+
## Discovery
|
|
52
|
+
|
|
53
|
+
On every full state update the engine builds a flat **catalog** of settable
|
|
54
|
+
parameters by reading the device's own capability metadata — so it enumerates
|
|
55
|
+
whatever modules/cards a given Hapi/Horus/Anubis actually has:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
eng.on('catalog', (cat) => {
|
|
59
|
+
// [{ moduleId, moduleName, section, key, value, unit, min, max, step, enum, settable, confidence }, ...]
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`confidence` is `confirmed` (set-frame verified on a real device), `inferred`
|
|
64
|
+
(derived from the state shape; very likely correct), or `unknown`.
|
|
65
|
+
|
|
66
|
+
## API (summary)
|
|
67
|
+
|
|
68
|
+
- `new RavennaEngine({ host, path?, livenessMs?, backoff? })`
|
|
69
|
+
- `.connect()`, `.close()`
|
|
70
|
+
- `.setParam(moduleId, key, value, { channelIndex? })` — high-level, unit-aware, clamped
|
|
71
|
+
- `.setModuleOuts(moduleId, valueObj)` — mid-level
|
|
72
|
+
- `.publishSettings(path, value)` — raw escape hatch
|
|
73
|
+
- `.catalog`, `.tree`, `.capabilities`, `.online`
|
|
74
|
+
- events: `online, offline, status, settings, statusmsg, errors, tree, catalog, param, error`
|
|
75
|
+
|
|
76
|
+
## Testing
|
|
77
|
+
|
|
78
|
+
```sh
|
|
79
|
+
npm test # hermetic: catalog + watchdog + meter + feedback logic, no device needed
|
|
80
|
+
MERGING_HOST=192.168.0.150 npm run validate # read-only: discover catalog + write a JSON report
|
|
81
|
+
MERGING_HOST=192.168.0.150 MERGING_I_UNDERSTAND=1 npm run validate # write-validate (audio OFF): set->re-read every param, restore
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The hermetic suite includes a fake-clock watchdog test that simulates a power
|
|
85
|
+
cycle and asserts the backoff schedule and the single online/offline transitions.
|
|
86
|
+
`npm run validate` (`scripts/validate-device.js`) discovers every settable parameter,
|
|
87
|
+
and — with `MERGING_I_UNDERSTAND=1`, audio off — validates each by SET→RE-READ: it
|
|
88
|
+
sets the value, re-reads the device to confirm it applied, restores the original, and
|
|
89
|
+
writes a JSON report (device fingerprint, per-param verdicts, unmodeled leaves) you can
|
|
90
|
+
share. A meter pre-flight gate aborts if any output is passing audio.
|
|
91
|
+
|
|
92
|
+
## Extending to new parameters / devices
|
|
93
|
+
|
|
94
|
+
Confirmed and inferred set-frame shapes live in `src/paths.js`. To add a parameter
|
|
95
|
+
(or support an Anubis monitor control), capture one set frame from the device web UI
|
|
96
|
+
and add a descriptor there — no engine changes needed.
|
|
97
|
+
|
|
98
|
+
## License
|
|
99
|
+
|
|
100
|
+
MIT
|
package/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { RavennaEngine } = require('./src/engine');
|
|
4
|
+
const { buildCatalog, groupByModule } = require('./src/catalog');
|
|
5
|
+
const { KNOWN, checkCompat, describeCompat } = require('./src/compat');
|
|
6
|
+
const paths = require('./src/paths');
|
|
7
|
+
|
|
8
|
+
module.exports = {
|
|
9
|
+
RavennaEngine,
|
|
10
|
+
buildCatalog,
|
|
11
|
+
groupByModule,
|
|
12
|
+
paths,
|
|
13
|
+
compat: { KNOWN, checkCompat, describeCompat }
|
|
14
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "merging-ravenna",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Standalone client for Merging Technologies RAVENNA devices (Hapi / Horus / Anubis) over their CometD/WebSocket control protocol. Unofficial; not affiliated with Merging Technologies.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"merging",
|
|
7
|
+
"ravenna",
|
|
8
|
+
"aes67",
|
|
9
|
+
"hapi",
|
|
10
|
+
"horus",
|
|
11
|
+
"anubis",
|
|
12
|
+
"cometd",
|
|
13
|
+
"bayeux"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "Matthew Robbetts",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/mrobbetts/merging_ravenna.git"
|
|
20
|
+
},
|
|
21
|
+
"main": "index.js",
|
|
22
|
+
"type": "commonjs",
|
|
23
|
+
"files": [
|
|
24
|
+
"index.js",
|
|
25
|
+
"src/",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE"
|
|
28
|
+
],
|
|
29
|
+
"scripts": {
|
|
30
|
+
"test": "node --test",
|
|
31
|
+
"validate": "node scripts/validate-device.js"
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"ws": "^8.18.0"
|
|
35
|
+
},
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=18"
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/catalog.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { moduleOutsPath, moduleInsPath, PARAMS, tenthsToDb } = require('./paths');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* buildCatalog(tree)
|
|
7
|
+
* ------------------
|
|
8
|
+
* Walk a full device state tree (the value of a path:"$" update) and produce a
|
|
9
|
+
* FLAT array of parameter entries, each carrying enough metadata to group back
|
|
10
|
+
* into a tree, drive a dropdown, or feed a Stream Deck.
|
|
11
|
+
*
|
|
12
|
+
* This is intentionally GENERIC: it reads whatever modules exist and whatever
|
|
13
|
+
* capabilities they advertise, so it enumerates a Hapi's D/A and mic-pre cards,
|
|
14
|
+
* a Horus's banks, or an Anubis's monitor section without hardcoded knowledge.
|
|
15
|
+
*
|
|
16
|
+
* Entry shape:
|
|
17
|
+
* {
|
|
18
|
+
* moduleId, moduleName, moduleType, moduleSubType,
|
|
19
|
+
* section: 'outs'|'ins',
|
|
20
|
+
* key, // e.g. 'attenuation', 'mute', 'roll_off_filter'
|
|
21
|
+
* path, // device JSONPath to the section object
|
|
22
|
+
* value, // current value (dB for tenths-db params, else raw)
|
|
23
|
+
* raw, // raw stored value (tenths for db params)
|
|
24
|
+
* unit, // 'dB'|'bool'|'enum'|'int'
|
|
25
|
+
* min, max, step, // numeric range where advertised (in display units)
|
|
26
|
+
* enum, // { label: intValue } for enum params, else null
|
|
27
|
+
* channelCount, // for per-channel params
|
|
28
|
+
* settable, // true only where we have a confirmed/inferred setter
|
|
29
|
+
* confidence // 'confirmed'|'inferred'|'unknown'
|
|
30
|
+
* }
|
|
31
|
+
*/
|
|
32
|
+
function buildCatalog(tree) {
|
|
33
|
+
const out = [];
|
|
34
|
+
const mods = tree && tree._modules;
|
|
35
|
+
if (!Array.isArray(mods)) return out;
|
|
36
|
+
|
|
37
|
+
for (const m of mods) {
|
|
38
|
+
const custom = m && m.custom;
|
|
39
|
+
if (!custom) continue;
|
|
40
|
+
|
|
41
|
+
for (const section of ['outs', 'ins']) {
|
|
42
|
+
const sec = custom[section];
|
|
43
|
+
if (!sec) continue;
|
|
44
|
+
const caps = sec.capabilities || {};
|
|
45
|
+
const path = section === 'outs' ? moduleOutsPath(m.id) : moduleInsPath(m.id);
|
|
46
|
+
const channelCount = Array.isArray(sec.channels) ? sec.channels.length : undefined;
|
|
47
|
+
|
|
48
|
+
const base = {
|
|
49
|
+
moduleId: m.id,
|
|
50
|
+
moduleName: m.name || null,
|
|
51
|
+
moduleType: m.type,
|
|
52
|
+
moduleSubType: m.sub_type,
|
|
53
|
+
section,
|
|
54
|
+
path,
|
|
55
|
+
channelCount
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// attenuation (gain/volume)
|
|
59
|
+
if (typeof sec.attenuation === 'number' && caps.attenuation) {
|
|
60
|
+
const info = caps.attenuation_info || {};
|
|
61
|
+
out.push(Object.assign({}, base, {
|
|
62
|
+
key: 'attenuation',
|
|
63
|
+
raw: sec.attenuation,
|
|
64
|
+
value: tenthsToDb(sec.attenuation),
|
|
65
|
+
unit: 'dB',
|
|
66
|
+
min: info.min != null ? tenthsToDb(info.min) : null,
|
|
67
|
+
max: info.max != null ? tenthsToDb(info.max) : null,
|
|
68
|
+
step: info.step != null ? tenthsToDb(info.step) : null,
|
|
69
|
+
enum: null,
|
|
70
|
+
settable: true,
|
|
71
|
+
confidence: PARAMS.attenuation.confidence
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// mute
|
|
76
|
+
if (typeof sec.mute === 'boolean' && caps.mute) {
|
|
77
|
+
out.push(Object.assign({}, base, {
|
|
78
|
+
key: 'mute', raw: sec.mute, value: sec.mute, unit: 'bool',
|
|
79
|
+
min: null, max: null, step: null, enum: null,
|
|
80
|
+
settable: true, confidence: PARAMS.mute.confidence
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// roll-off filter (enum)
|
|
85
|
+
if (typeof sec.roll_off_filter === 'number' && caps.roll_off_filter) {
|
|
86
|
+
out.push(Object.assign({}, base, {
|
|
87
|
+
key: 'roll_off_filter', raw: sec.roll_off_filter, value: sec.roll_off_filter,
|
|
88
|
+
unit: 'enum', min: null, max: null, step: null,
|
|
89
|
+
enum: caps.roll_off_filters || null,
|
|
90
|
+
settable: true, confidence: PARAMS.roll_off_filter.confidence
|
|
91
|
+
}));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// out max level
|
|
95
|
+
if (typeof sec.out_max_level === 'number' && caps.out_max_level) {
|
|
96
|
+
out.push(Object.assign({}, base, {
|
|
97
|
+
key: 'out_max_level', raw: sec.out_max_level, value: sec.out_max_level,
|
|
98
|
+
unit: 'int', min: null, max: null, step: null, enum: null,
|
|
99
|
+
settable: true, confidence: PARAMS.out_max_level.confidence
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// per-channel trim
|
|
104
|
+
if (Array.isArray(sec.channels) && caps.channel && caps.channel.trim) {
|
|
105
|
+
const info = (caps.channel && caps.channel.trim_info) || {};
|
|
106
|
+
sec.channels.forEach((ch, idx) => {
|
|
107
|
+
if (ch && typeof ch.trim === 'number') {
|
|
108
|
+
out.push(Object.assign({}, base, {
|
|
109
|
+
key: 'channel_trim', channelIndex: idx,
|
|
110
|
+
raw: ch.trim, value: tenthsToDb(ch.trim), unit: 'dB',
|
|
111
|
+
min: info.min != null ? tenthsToDb(info.min) : null,
|
|
112
|
+
max: info.max != null ? tenthsToDb(info.max) : null,
|
|
113
|
+
step: info.step != null ? tenthsToDb(info.step) : null,
|
|
114
|
+
enum: null,
|
|
115
|
+
settable: true, confidence: PARAMS.channel_trim.confidence
|
|
116
|
+
}));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Convenience: catalog grouped by module, for tree-style display. */
|
|
126
|
+
function groupByModule(catalog) {
|
|
127
|
+
const map = new Map();
|
|
128
|
+
for (const e of catalog) {
|
|
129
|
+
if (!map.has(e.moduleId)) {
|
|
130
|
+
map.set(e.moduleId, { moduleId: e.moduleId, moduleName: e.moduleName, moduleType: e.moduleType, params: [] });
|
|
131
|
+
}
|
|
132
|
+
map.get(e.moduleId).params.push(e);
|
|
133
|
+
}
|
|
134
|
+
return Array.from(map.values());
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = { buildCatalog, groupByModule };
|
package/src/compat.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* compat.js
|
|
5
|
+
* ---------
|
|
6
|
+
* Known-good device/firmware fingerprints. The control protocol is undocumented
|
|
7
|
+
* and could change between firmware releases, so we record which combinations
|
|
8
|
+
* have actually been validated and warn when we see something we haven't.
|
|
9
|
+
*
|
|
10
|
+
* Matching is GENERATION-AWARE on purpose: Merging ships frequent point builds,
|
|
11
|
+
* and we don't want a scary warning on every new b-number within a firmware
|
|
12
|
+
* generation whose protocol we've already confirmed. A new *generation* or a new
|
|
13
|
+
* *product*, on the other hand, is a real "be careful" signal.
|
|
14
|
+
*
|
|
15
|
+
* Levels returned by checkCompat():
|
|
16
|
+
* 'known-good' exact product + generation + firmware version validated
|
|
17
|
+
* 'known-generation' product + generation match; different point release (likely fine)
|
|
18
|
+
* 'unknown-generation' product matches but generation not seen (caution)
|
|
19
|
+
* 'unknown' product never seen (high caution)
|
|
20
|
+
*
|
|
21
|
+
* `confirmedShapes` documents which parameter SET shapes we have actually verified
|
|
22
|
+
* on this fingerprint (vs. merely inferred), so tools can be honest about it.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const KNOWN = [
|
|
26
|
+
{
|
|
27
|
+
product: 'HAPI_MkII',
|
|
28
|
+
firmwareGeneration: 2,
|
|
29
|
+
firmwareVersions: ['1.9.0b62872'],
|
|
30
|
+
status: 'verified',
|
|
31
|
+
// serial recorded only as provenance of where validation happened
|
|
32
|
+
validatedOn: 'maintainer unit (serial H96149, D/A card sub_type 218, hw run 14)',
|
|
33
|
+
confirmedShapes: ['attenuation', 'mute', 'roll_off_filter', 'out_max_level', 'channel_trim'],
|
|
34
|
+
inferredShapes: [],
|
|
35
|
+
notes: 'All five out-param set-shapes confirmed on hardware: attenuation from captured web-UI frames; mute/roll_off_filter/out_max_level/channel_trim via active set->re-read (scripts/set-probe.js, 2026-06-05). Note the device echoes non-attenuation changes at the module-root path, not .custom.outs.'
|
|
36
|
+
}
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
function checkCompat(tree) {
|
|
40
|
+
const product = (tree && tree.identity && tree.identity.product) || null;
|
|
41
|
+
const fw = (tree && tree._firmware_version) || null;
|
|
42
|
+
const gen = (tree && tree._firmware_generation != null) ? tree._firmware_generation : null;
|
|
43
|
+
|
|
44
|
+
const result = { level: 'unknown', product, firmware: fw, generation: gen, entry: null };
|
|
45
|
+
|
|
46
|
+
const genMatch = KNOWN.find((k) => k.product === product && k.firmwareGeneration === gen);
|
|
47
|
+
if (genMatch) {
|
|
48
|
+
result.entry = genMatch;
|
|
49
|
+
result.level = genMatch.firmwareVersions.includes(fw) ? 'known-good' : 'known-generation';
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
const productMatch = KNOWN.find((k) => k.product === product);
|
|
53
|
+
if (productMatch) {
|
|
54
|
+
result.entry = productMatch;
|
|
55
|
+
result.level = 'unknown-generation';
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Human-readable one-liner for logs / status tooltips. */
|
|
62
|
+
function describeCompat(c) {
|
|
63
|
+
const base = `${c.product || 'unknown product'} fw ${c.firmware || '?'} (gen ${c.generation == null ? '?' : c.generation})`;
|
|
64
|
+
switch (c.level) {
|
|
65
|
+
case 'known-good': return `KNOWN-GOOD: ${base} — validated.`;
|
|
66
|
+
case 'known-generation': return `KNOWN-GENERATION: ${base} — same generation as a validated build; protocol almost certainly identical.`;
|
|
67
|
+
case 'unknown-generation': return `UNKNOWN-GENERATION: ${base} — product known but this firmware generation has not been validated. Proceed with caution.`;
|
|
68
|
+
default: return `UNKNOWN: ${base} — this device/firmware has never been validated with this tool. Shapes are unverified.`;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
module.exports = { KNOWN, checkCompat, describeCompat };
|
package/src/engine.js
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const EventEmitter = require('events');
|
|
4
|
+
const WebSocket = require('ws');
|
|
5
|
+
const { buildCatalog } = require('./catalog');
|
|
6
|
+
const { checkCompat } = require('./compat');
|
|
7
|
+
const { moduleOutsPath, PARAMS, dbToTenths } = require('./paths');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* RavennaEngine
|
|
11
|
+
* -------------
|
|
12
|
+
* One resilient CometD (Bayeux) session to a Merging RAVENNA device over WebSocket.
|
|
13
|
+
*
|
|
14
|
+
* Resilience model (two distinct failure modes, handled differently):
|
|
15
|
+
* - socket drop / device power-off -> exponential backoff reconnect, forever
|
|
16
|
+
* - device wedged but socket "open" -> data-liveness watchdog: if no frame
|
|
17
|
+
* arrives within `livenessMs`, declare stale, tear down, fall into backoff.
|
|
18
|
+
*
|
|
19
|
+
* The device emits /ravenna/status roughly every 2 s, so silence beyond ~7 s
|
|
20
|
+
* is a reliable "gone" signal.
|
|
21
|
+
*
|
|
22
|
+
* online/offline are emitted ONCE PER TRANSITION (edge-triggered), so consumers
|
|
23
|
+
* (e.g. a Stream Deck display) can react without being spammed.
|
|
24
|
+
*
|
|
25
|
+
* Events:
|
|
26
|
+
* 'online' () device reachable AND full state received
|
|
27
|
+
* 'offline' (reason) device unreachable/stale; reason: 'timeout'|'closed'|'connect-failed'
|
|
28
|
+
* 'status' (str) fine-grained: 'starting'|'open'|'handshaking'|'connected'|'stale'|'reconnecting'
|
|
29
|
+
* 'settings' (data) a /ravenna/settings broadcast { path, value }
|
|
30
|
+
* 'statusmsg'(data) a /ravenna/status broadcast { path, value }
|
|
31
|
+
* 'errors' (data) a /ravenna/errors broadcast
|
|
32
|
+
* 'tree' (tree) full state tree (path:"$")
|
|
33
|
+
* 'catalog' (array) flattened parameter catalog (rebuilt on every tree)
|
|
34
|
+
* 'param' (entry) a single changed parameter we could resolve from a settings echo
|
|
35
|
+
* 'error' (Error)
|
|
36
|
+
*/
|
|
37
|
+
class RavennaEngine extends EventEmitter {
|
|
38
|
+
constructor(opts = {}) {
|
|
39
|
+
super();
|
|
40
|
+
const host = opts.host || '127.0.0.1';
|
|
41
|
+
const path = opts.path || '/cometd/handshake';
|
|
42
|
+
this.url = opts.url || `ws://${host}${path}`;
|
|
43
|
+
this.origin = opts.origin || `http://${host}`;
|
|
44
|
+
this.host = host;
|
|
45
|
+
|
|
46
|
+
this.livenessMs = opts.livenessMs || 7000; // staleness window
|
|
47
|
+
this.backoff = opts.backoff || [2000, 5000, 15000, 30000]; // capped schedule
|
|
48
|
+
this._backoffIdx = 0;
|
|
49
|
+
|
|
50
|
+
// Meter calibration. The level integers are CONFIRMED linear amplitude
|
|
51
|
+
// (see meterLevelsFor / dbFromLevel). `meterFullScale` is the integer that
|
|
52
|
+
// maps to 0 dBFS: CONFIRMED 65535 (2^16-1, 16-bit unsigned) by feeding a 0 dBFS
|
|
53
|
+
// tone to a RAVENNA input (meter read 65534). Overridable for other hardware.
|
|
54
|
+
this.meterFullScale = opts.meterFullScale || 65535;
|
|
55
|
+
|
|
56
|
+
// injectable for tests
|
|
57
|
+
this._WebSocket = opts.WebSocket || WebSocket;
|
|
58
|
+
this._setTimeout = opts.setTimeout || setTimeout;
|
|
59
|
+
this._clearTimeout = opts.clearTimeout || clearTimeout;
|
|
60
|
+
|
|
61
|
+
this.ws = null;
|
|
62
|
+
this.clientId = null;
|
|
63
|
+
this.msgId = 0;
|
|
64
|
+
this.online = false; // public: device reachable + state known
|
|
65
|
+
this._closing = false;
|
|
66
|
+
this._reconnectTimer = null;
|
|
67
|
+
this._livenessTimer = null;
|
|
68
|
+
|
|
69
|
+
this.tree = null;
|
|
70
|
+
this.catalog = [];
|
|
71
|
+
this.capabilities = {};
|
|
72
|
+
this.compat = null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ---- lifecycle ----------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
connect() {
|
|
78
|
+
this._closing = false;
|
|
79
|
+
this._backoffIdx = 0;
|
|
80
|
+
this.emit('status', 'starting');
|
|
81
|
+
this._open();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
close() {
|
|
85
|
+
this._closing = true;
|
|
86
|
+
this._clearReconnect();
|
|
87
|
+
this._clearLiveness();
|
|
88
|
+
try { if (this.ws) this.ws.close(); } catch (e) { /* ignore */ }
|
|
89
|
+
this._goOffline('closed');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ---- low-level send -----------------------------------------------------
|
|
93
|
+
|
|
94
|
+
_nextId() { return String(++this.msgId); }
|
|
95
|
+
|
|
96
|
+
_send(arr) {
|
|
97
|
+
try {
|
|
98
|
+
if (this.ws && this.ws.readyState === this._WebSocket.OPEN) {
|
|
99
|
+
this.ws.send(JSON.stringify(arr));
|
|
100
|
+
}
|
|
101
|
+
} catch (e) { this.emit('error', e); }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ---- CometD steps -------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
_handshake() {
|
|
107
|
+
this.emit('status', 'handshaking');
|
|
108
|
+
this._send([{
|
|
109
|
+
version: '1.0', minimumVersion: '0.9', channel: '/meta/handshake',
|
|
110
|
+
supportedConnectionTypes: ['websocket', 'long-polling', 'callback-polling'],
|
|
111
|
+
advice: { timeout: 60000, interval: 0 }, id: this._nextId()
|
|
112
|
+
}]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_connectLoop() {
|
|
116
|
+
if (!this.clientId) return;
|
|
117
|
+
this._send([{ channel: '/meta/connect', connectionType: 'websocket', id: this._nextId(), clientId: this.clientId }]);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
_subscribeAndSync() {
|
|
121
|
+
this._send([
|
|
122
|
+
{ channel: '/meta/subscribe', subscription: '/ravenna/settings', id: this._nextId(), clientId: this.clientId },
|
|
123
|
+
{ channel: '/meta/subscribe', subscription: '/ravenna/status', id: this._nextId(), clientId: this.clientId },
|
|
124
|
+
{ channel: '/meta/subscribe', subscription: '/ravenna/errors', id: this._nextId(), clientId: this.clientId },
|
|
125
|
+
{ channel: '/service/ravenna/commands', data: { command: 'update' }, id: this._nextId(), clientId: this.clientId }
|
|
126
|
+
]);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- public control -----------------------------------------------------
|
|
130
|
+
|
|
131
|
+
/** Publish a raw { path, value } settings change. */
|
|
132
|
+
publishSettings(path, value) {
|
|
133
|
+
if (!this.clientId) return false;
|
|
134
|
+
this._send([{ channel: '/service/ravenna/settings', data: { path, value }, id: this._nextId(), clientId: this.clientId }]);
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Publish a value object to a module's outs. */
|
|
139
|
+
setModuleOuts(moduleId, valueObj) {
|
|
140
|
+
return this.publishSettings(moduleOutsPath(moduleId), valueObj);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Ask the device to re-send the full state tree (fires a 'tree' event when it arrives). */
|
|
144
|
+
requestUpdate() {
|
|
145
|
+
if (!this.clientId) return false;
|
|
146
|
+
this._send([{ channel: '/service/ravenna/commands', data: { command: 'update' }, id: this._nextId(), clientId: this.clientId }]);
|
|
147
|
+
return true;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Pull the per-channel level array for one module out of a /ravenna/meter frame.
|
|
152
|
+
* Frame shape (confirmed from capture): data.value.state._modules is an array of
|
|
153
|
+
* { id, type, meters: { ins?: {levels,levels_hold}, outs?: {levels,levels_hold} } }.
|
|
154
|
+
* `levels` are non-negative integers; 0 == digital black.
|
|
155
|
+
*
|
|
156
|
+
* The integers are CONFIRMED to be LINEAR amplitude (not dB, not power). This was
|
|
157
|
+
* proven from a live capture: the D/A (id 60, attenuation -40.0 dB) output meter
|
|
158
|
+
* reads exactly floor(StreamInput * 0.01) channel-by-channel and frame-by-frame
|
|
159
|
+
* (e.g. holds [19538,7074,18158,7086] -> [195,70,181,70]). The -40 dB attenuation
|
|
160
|
+
* is a built-in known reference that fixes the slope: dB = 20*log10(level/fullScale).
|
|
161
|
+
*
|
|
162
|
+
* @param field 'levels' (instantaneous, default) or 'levels_hold' (latched peak).
|
|
163
|
+
*/
|
|
164
|
+
static meterLevelsFor(frame, moduleId, section = 'outs', field = 'levels') {
|
|
165
|
+
const mods = frame && frame.data && frame.data.value && frame.data.value.state
|
|
166
|
+
&& frame.data.value.state._modules;
|
|
167
|
+
if (!Array.isArray(mods)) return null;
|
|
168
|
+
const mod = mods.find((m) => m && m.id === moduleId);
|
|
169
|
+
const sec = mod && mod.meters && mod.meters[section];
|
|
170
|
+
return (sec && Array.isArray(sec[field])) ? sec[field] : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Convert a linear meter level integer to dBFS.
|
|
175
|
+
* Slope/linearity and the 0 dBFS reference are both CONFIRMED: a 0 dBFS tone read
|
|
176
|
+
* 65534, so `fullScale` defaults to 65535 (2^16-1, 16-bit unsigned). Override for
|
|
177
|
+
* other hardware. Returns -Infinity for level <= 0 (digital black).
|
|
178
|
+
*/
|
|
179
|
+
static dbFromLevel(level, fullScale = 65535) {
|
|
180
|
+
if (!(level > 0)) return -Infinity;
|
|
181
|
+
return 20 * Math.log10(level / fullScale);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Best-effort metering sample, used as a pre-write safety gate.
|
|
186
|
+
*
|
|
187
|
+
* Subscribes to the confirmed /ravenna/meter channel (auto-publishes ~every 100ms)
|
|
188
|
+
* and watches instantaneous `levels` for the gated module across the window. The
|
|
189
|
+
* go/no-go is based on instantaneous levels (currently-flowing audio), NOT
|
|
190
|
+
* `levels_hold`, which is a latched peak that can reflect audio from before the
|
|
191
|
+
* sample and would cause false aborts. The latched hold IS surfaced as `maxRawHold`
|
|
192
|
+
* for operator context ("a peak was here recently").
|
|
193
|
+
*
|
|
194
|
+
* Resolves: { confirmed, silent, maxRaw, maxRawHold, maxRawAnywhere, maxDb, fullScale, raw }
|
|
195
|
+
* confirmed false -> no meter frame arrived; silent is null (callers MUST fail-safe)
|
|
196
|
+
* confirmed true -> silent === (maxRaw === 0); maxDb is the gated module peak in dBFS
|
|
197
|
+
*/
|
|
198
|
+
|
|
199
|
+
sampleMeters(windowMs = 1500, { moduleId = 60, section = 'outs' } = {}) {
|
|
200
|
+
return new Promise((resolve) => {
|
|
201
|
+
const raw = [];
|
|
202
|
+
let maxForModule = 0; // peak instantaneous integer for the gated module
|
|
203
|
+
let maxHoldForModule = 0; // peak latched-hold integer for the gated module
|
|
204
|
+
let maxAnywhere = 0; // peak instantaneous integer across ALL meters (informational)
|
|
205
|
+
let sawFrame = false;
|
|
206
|
+
const peakOf = (arr) => (Array.isArray(arr) ? arr.reduce((a, b) => (b > a ? b : a), 0) : 0);
|
|
207
|
+
const onRaw = (m) => {
|
|
208
|
+
if (!m || m.channel !== '/ravenna/meter') return;
|
|
209
|
+
sawFrame = true;
|
|
210
|
+
raw.push(m);
|
|
211
|
+
const mods = m.data && m.data.value && m.data.value.state && m.data.value.state._modules;
|
|
212
|
+
if (Array.isArray(mods)) {
|
|
213
|
+
for (const mod of mods) {
|
|
214
|
+
for (const sec of ['ins', 'outs']) {
|
|
215
|
+
const meters = mod && mod.meters && mod.meters[sec];
|
|
216
|
+
if (!meters) continue;
|
|
217
|
+
const peak = peakOf(meters.levels);
|
|
218
|
+
if (peak > maxAnywhere) maxAnywhere = peak;
|
|
219
|
+
if (mod.id === moduleId && sec === section) {
|
|
220
|
+
if (peak > maxForModule) maxForModule = peak;
|
|
221
|
+
const hold = peakOf(meters.levels_hold);
|
|
222
|
+
if (hold > maxHoldForModule) maxHoldForModule = hold;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
this.on('raw', onRaw);
|
|
229
|
+
|
|
230
|
+
// Subscribe to the confirmed meter channel. auto_publish_vumeter is on by
|
|
231
|
+
// default, so frames should already be flowing once subscribed.
|
|
232
|
+
try {
|
|
233
|
+
if (this.clientId) {
|
|
234
|
+
this._send([{ channel: '/meta/subscribe', subscription: '/ravenna/meter', id: this._nextId(), clientId: this.clientId }]);
|
|
235
|
+
}
|
|
236
|
+
} catch (e) { /* ignore */ }
|
|
237
|
+
|
|
238
|
+
this._setTimeout(() => {
|
|
239
|
+
this.off('raw', onRaw);
|
|
240
|
+
if (!sawFrame) {
|
|
241
|
+
// No meter frames arrived -> cannot confirm silence. Fail safe.
|
|
242
|
+
resolve({ confirmed: false, silent: null, maxRaw: null, maxRawHold: null, maxRawAnywhere: null, maxDb: null, fullScale: this.meterFullScale, raw });
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
resolve({
|
|
246
|
+
confirmed: true,
|
|
247
|
+
silent: maxForModule === 0, // 0 == digital black; instantaneous decides go/no-go
|
|
248
|
+
maxRaw: maxForModule, // peak instantaneous linear level for the gated module
|
|
249
|
+
maxRawHold: maxHoldForModule, // latched peak-hold (may predate the sample; context only)
|
|
250
|
+
maxRawAnywhere: maxAnywhere, // any other hot meter (e.g. live stream inputs)
|
|
251
|
+
maxDb: maxForModule > 0 ? RavennaEngine.dbFromLevel(maxForModule, this.meterFullScale) : null,
|
|
252
|
+
fullScale: this.meterFullScale, // 0 dBFS reference (assumed 2^15; see dbFromLevel)
|
|
253
|
+
raw
|
|
254
|
+
});
|
|
255
|
+
}, windowMs);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* High-level set by parameter name, using PARAMS descriptors.
|
|
261
|
+
* For 'tenths-db' params, pass dB; the library converts and clamps to caps.
|
|
262
|
+
* ctx may carry { channelIndex } for per-channel params.
|
|
263
|
+
*/
|
|
264
|
+
setParam(moduleId, key, value, ctx = {}) {
|
|
265
|
+
const desc = PARAMS[key];
|
|
266
|
+
if (!desc) throw new Error(`Unknown parameter '${key}'`);
|
|
267
|
+
|
|
268
|
+
let v = value;
|
|
269
|
+
if (desc.unit === 'tenths-db') {
|
|
270
|
+
v = dbToTenths(value);
|
|
271
|
+
const cap = this._capFor(moduleId, key);
|
|
272
|
+
if (cap) v = Math.min(cap.max, Math.max(cap.min, v)); // clamp in tenths
|
|
273
|
+
}
|
|
274
|
+
const channelCount = this._channelCountFor(moduleId, desc.section);
|
|
275
|
+
const valueObj = desc.build(v, { channelCount, channelIndex: ctx.channelIndex || 0 });
|
|
276
|
+
const path = desc.section === 'ins'
|
|
277
|
+
? require('./paths').moduleInsPath(moduleId)
|
|
278
|
+
: moduleOutsPath(moduleId);
|
|
279
|
+
return this.publishSettings(path, valueObj);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
_capFor(moduleId, key) {
|
|
283
|
+
const c = this.capabilities[moduleId];
|
|
284
|
+
if (!c) return null;
|
|
285
|
+
if (key === 'attenuation') return c.attenuation || null;
|
|
286
|
+
if (key === 'channel_trim') return c.trim || null;
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
_channelCountFor(moduleId, section) {
|
|
291
|
+
if (!this.tree || !Array.isArray(this.tree._modules)) return 1;
|
|
292
|
+
const m = this.tree._modules.find((x) => x.id === moduleId);
|
|
293
|
+
const sec = m && m.custom && m.custom[section];
|
|
294
|
+
return (sec && Array.isArray(sec.channels)) ? sec.channels.length : 1;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---- inbound ------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
_ingestTree(tree) {
|
|
300
|
+
this.tree = tree;
|
|
301
|
+
this.capabilities = {};
|
|
302
|
+
this._outsCache = {}; // per-module last-known outs scalars, for change-detection in feedback echoes
|
|
303
|
+
if (Array.isArray(tree._modules)) {
|
|
304
|
+
for (const m of tree._modules) {
|
|
305
|
+
const outs = m && m.custom && m.custom.outs;
|
|
306
|
+
if (outs) this._outsCache[m.id] = this._snapshotOuts(outs);
|
|
307
|
+
const caps = outs && outs.capabilities;
|
|
308
|
+
if (caps) {
|
|
309
|
+
this.capabilities[m.id] = {
|
|
310
|
+
attenuation: caps.attenuation_info || null,
|
|
311
|
+
trim: (caps.channel && caps.channel.trim_info) || null,
|
|
312
|
+
mute: !!caps.mute,
|
|
313
|
+
rollOff: caps.roll_off_filters || null,
|
|
314
|
+
name: m.name || null
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
this.catalog = buildCatalog(tree);
|
|
320
|
+
this.compat = checkCompat(tree);
|
|
321
|
+
this.emit('tree', tree);
|
|
322
|
+
this.emit('catalog', this.catalog);
|
|
323
|
+
this.emit('compat', this.compat);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
_handleMessage(m) {
|
|
327
|
+
this._kickLiveness(); // any frame = device alive
|
|
328
|
+
this.emit('raw', m); // expose every frame (used by meter sampling / debugging)
|
|
329
|
+
|
|
330
|
+
const ch = m.channel;
|
|
331
|
+
if (ch === '/meta/handshake') {
|
|
332
|
+
if (m.successful && m.clientId) {
|
|
333
|
+
this.clientId = m.clientId;
|
|
334
|
+
this.emit('status', 'connected');
|
|
335
|
+
this._backoffIdx = 0;
|
|
336
|
+
this._subscribeAndSync();
|
|
337
|
+
this._connectLoop();
|
|
338
|
+
} else {
|
|
339
|
+
this._teardownAndReconnect('connect-failed');
|
|
340
|
+
}
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (ch === '/meta/connect') {
|
|
344
|
+
if (m.successful) this._connectLoop();
|
|
345
|
+
else this._teardownAndReconnect('connect-failed');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (ch === '/meta/subscribe') return;
|
|
349
|
+
|
|
350
|
+
if (ch === '/ravenna/settings' || ch === '/ravenna/status') {
|
|
351
|
+
const d = m.data;
|
|
352
|
+
if (d && d.path === '$' && d.value) {
|
|
353
|
+
// Only /ravenna/settings carries the AUTHORITATIVE full tree (modules with
|
|
354
|
+
// custom/capabilities). The periodic /ravenna/status '$' is a REDUCED tree
|
|
355
|
+
// (modules carry only {state,id,type}); ingesting it would wipe capabilities
|
|
356
|
+
// and the catalog every ~2 s. So ingest from settings only — but either '$'
|
|
357
|
+
// is sufficient to declare the device reachable.
|
|
358
|
+
if (ch === '/ravenna/settings') this._ingestTree(d.value);
|
|
359
|
+
if (!this.online) { this.online = true; this.emit('online'); }
|
|
360
|
+
}
|
|
361
|
+
if (ch === '/ravenna/settings') {
|
|
362
|
+
this.emit('settings', d);
|
|
363
|
+
this._maybeEmitParam(d);
|
|
364
|
+
} else {
|
|
365
|
+
this.emit('statusmsg', d);
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
if (ch === '/ravenna/errors') { this.emit('errors', m.data); }
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Compact snapshot of an outs node's settable scalars, for change-detection. */
|
|
373
|
+
_snapshotOuts(o) {
|
|
374
|
+
return {
|
|
375
|
+
attenuation: typeof o.attenuation === 'number' ? o.attenuation : undefined,
|
|
376
|
+
mute: typeof o.mute === 'boolean' ? o.mute : undefined,
|
|
377
|
+
roll_off_filter: typeof o.roll_off_filter === 'number' ? o.roll_off_filter : undefined,
|
|
378
|
+
out_max_level: typeof o.out_max_level === 'number' ? o.out_max_level : undefined,
|
|
379
|
+
trims: Array.isArray(o.channels) ? o.channels.map((c) => (c && typeof c.trim === 'number' ? c.trim : undefined)) : undefined
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Resolve a /ravenna/settings echo into catalog-style param change event(s).
|
|
384
|
+
// The device echoes at TWO observed granularities (confirmed from a live capture):
|
|
385
|
+
// - narrow: $._modules[?(@.id==N)][0].custom.outs value = { attenuation: -399 }
|
|
386
|
+
// (attenuation drives this path; value IS the outs delta)
|
|
387
|
+
// - module-root: $._modules[?(@.id==N)][0] value = { ...whole module..., custom:{outs:{...}} }
|
|
388
|
+
// (mute / roll_off_filter / out_max_level / trim from web UI or front panel)
|
|
389
|
+
// Either way we resolve the outs object and emit only keys that actually CHANGED
|
|
390
|
+
// vs the last-known value (so a full-module echo doesn't spam unchanged channels).
|
|
391
|
+
_maybeEmitParam(d) {
|
|
392
|
+
if (!d || !d.path || typeof d.value !== 'object' || d.value === null) return;
|
|
393
|
+
const m = /_modules\[\?\(@\.id==(\d+)\)\]\[0\](?:\.custom\.(outs|ins))?\s*$/.exec(d.path);
|
|
394
|
+
if (!m) return;
|
|
395
|
+
const moduleId = parseInt(m[1], 10);
|
|
396
|
+
let outs;
|
|
397
|
+
if (m[2] === 'outs') outs = d.value; // narrow outs echo: value is the delta
|
|
398
|
+
else if (m[2] === 'ins') return; // ins feedback not modeled yet
|
|
399
|
+
else { const c = d.value.custom; outs = c && c.outs; } // module-root echo: dig into custom.outs
|
|
400
|
+
if (!outs || typeof outs !== 'object') return;
|
|
401
|
+
|
|
402
|
+
if (!this._outsCache) this._outsCache = {};
|
|
403
|
+
const prev = this._outsCache[moduleId] || (this._outsCache[moduleId] = {});
|
|
404
|
+
|
|
405
|
+
if (typeof outs.attenuation === 'number' && outs.attenuation !== prev.attenuation) {
|
|
406
|
+
prev.attenuation = outs.attenuation;
|
|
407
|
+
this.emit('param', { moduleId, key: 'attenuation', raw: outs.attenuation, value: outs.attenuation / 10, unit: 'dB' });
|
|
408
|
+
}
|
|
409
|
+
if (typeof outs.mute === 'boolean' && outs.mute !== prev.mute) {
|
|
410
|
+
prev.mute = outs.mute;
|
|
411
|
+
this.emit('param', { moduleId, key: 'mute', raw: outs.mute, value: outs.mute, unit: 'bool' });
|
|
412
|
+
}
|
|
413
|
+
if (typeof outs.roll_off_filter === 'number' && outs.roll_off_filter !== prev.roll_off_filter) {
|
|
414
|
+
prev.roll_off_filter = outs.roll_off_filter;
|
|
415
|
+
this.emit('param', { moduleId, key: 'roll_off_filter', raw: outs.roll_off_filter, value: outs.roll_off_filter, unit: 'enum' });
|
|
416
|
+
}
|
|
417
|
+
if (typeof outs.out_max_level === 'number' && outs.out_max_level !== prev.out_max_level) {
|
|
418
|
+
prev.out_max_level = outs.out_max_level;
|
|
419
|
+
this.emit('param', { moduleId, key: 'out_max_level', raw: outs.out_max_level, value: outs.out_max_level, unit: 'int' });
|
|
420
|
+
}
|
|
421
|
+
if (Array.isArray(outs.channels)) {
|
|
422
|
+
if (!Array.isArray(prev.trims)) prev.trims = [];
|
|
423
|
+
outs.channels.forEach((c, i) => {
|
|
424
|
+
if (c && typeof c.trim === 'number' && c.trim !== prev.trims[i]) {
|
|
425
|
+
prev.trims[i] = c.trim;
|
|
426
|
+
this.emit('param', { moduleId, key: 'channel_trim', channelIndex: i, raw: c.trim, value: c.trim / 10, unit: 'dB' });
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
_onFrame(raw) {
|
|
433
|
+
let arr;
|
|
434
|
+
try { arr = JSON.parse(raw); } catch (e) { return; }
|
|
435
|
+
if (!Array.isArray(arr)) arr = [arr];
|
|
436
|
+
for (const m of arr) this._handleMessage(m);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---- watchdog & reconnect ----------------------------------------------
|
|
440
|
+
|
|
441
|
+
_kickLiveness() {
|
|
442
|
+
this._clearLiveness();
|
|
443
|
+
this._livenessTimer = this._setTimeout(() => {
|
|
444
|
+
this.emit('status', 'stale');
|
|
445
|
+
this._teardownAndReconnect('timeout');
|
|
446
|
+
}, this.livenessMs);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
_clearLiveness() {
|
|
450
|
+
if (this._livenessTimer) { this._clearTimeout(this._livenessTimer); this._livenessTimer = null; }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
_clearReconnect() {
|
|
454
|
+
if (this._reconnectTimer) { this._clearTimeout(this._reconnectTimer); this._reconnectTimer = null; }
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
_goOffline(reason) {
|
|
458
|
+
const was = this.online;
|
|
459
|
+
this.online = false;
|
|
460
|
+
this.clientId = null;
|
|
461
|
+
if (was) this.emit('offline', reason);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
_teardownAndReconnect(reason) {
|
|
465
|
+
this._clearLiveness();
|
|
466
|
+
try { if (this.ws) { this.ws.removeAllListeners(); this.ws.close(); } } catch (e) { /* ignore */ }
|
|
467
|
+
this.ws = null;
|
|
468
|
+
this._goOffline(reason);
|
|
469
|
+
if (this._closing) return;
|
|
470
|
+
if (this._reconnectTimer) return;
|
|
471
|
+
const delay = this.backoff[Math.min(this._backoffIdx, this.backoff.length - 1)];
|
|
472
|
+
this._backoffIdx++;
|
|
473
|
+
this.emit('status', 'reconnecting');
|
|
474
|
+
this._reconnectTimer = this._setTimeout(() => { this._reconnectTimer = null; this._open(); }, delay);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
_open() {
|
|
478
|
+
let ws;
|
|
479
|
+
try {
|
|
480
|
+
ws = new this._WebSocket(this.url, { perMessageDeflate: true, origin: this.origin });
|
|
481
|
+
} catch (e) {
|
|
482
|
+
this.emit('error', e);
|
|
483
|
+
this._teardownAndReconnect('connect-failed');
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
this.ws = ws;
|
|
487
|
+
ws.on('open', () => {
|
|
488
|
+
this.emit('status', 'open');
|
|
489
|
+
this.msgId = 0; this.clientId = null;
|
|
490
|
+
this._kickLiveness();
|
|
491
|
+
this._handshake();
|
|
492
|
+
});
|
|
493
|
+
ws.on('message', (data) => this._onFrame(data.toString()));
|
|
494
|
+
ws.on('close', () => { if (!this._closing) this._teardownAndReconnect('closed'); });
|
|
495
|
+
ws.on('error', (e) => { this.emit('error', e); });
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
module.exports = { RavennaEngine };
|
package/src/paths.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* paths.js
|
|
5
|
+
* --------
|
|
6
|
+
* Knowledge about the RAVENNA control grammar, kept separate from connection
|
|
7
|
+
* logic so that new parameters (or new devices like Anubis) slot in here.
|
|
8
|
+
*
|
|
9
|
+
* The device addresses parameters with a JSONPath into its state tree, e.g.
|
|
10
|
+
* $._modules[?(@.id==60)][0].custom.outs
|
|
11
|
+
* and you SET by publishing { path, value } to /service/ravenna/settings,
|
|
12
|
+
* where `value` is a *partial* object merged into the node at `path`.
|
|
13
|
+
*
|
|
14
|
+
* CONFIDENCE levels reflect how sure we are of a parameter's SET shape:
|
|
15
|
+
* 'confirmed' - observed in a captured set frame from a real device
|
|
16
|
+
* 'inferred' - derived from the state/capability shape; very likely correct
|
|
17
|
+
* 'unknown' - discoverable (we can read its value) but no verified setter
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Build the standard module "outs" path. */
|
|
21
|
+
function moduleOutsPath(moduleId) {
|
|
22
|
+
return `$._modules[?(@.id==${moduleId})][0].custom.outs`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Build the standard module "ins" path (mic pre gains etc. live here on input cards). */
|
|
26
|
+
function moduleInsPath(moduleId) {
|
|
27
|
+
return `$._modules[?(@.id==${moduleId})][0].custom.ins`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Regex to recognise a feedback frame's path as belonging to a given module's outs.
|
|
32
|
+
* Matches e.g. _modules[?(@.id==60)][0].custom.outs (with or without leading $.).
|
|
33
|
+
*/
|
|
34
|
+
function moduleOutsPathRegex(moduleId) {
|
|
35
|
+
return new RegExp(`_modules\\[\\?\\(@\\.id==${moduleId}\\)\\]\\[0\\]\\.custom\\.outs`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parameter descriptors. `unit` drives value encoding:
|
|
40
|
+
* 'tenths-db' : device stores integer tenths of a dB; library converts to/from dB
|
|
41
|
+
* 'bool' : boolean
|
|
42
|
+
* 'enum' : integer mapped to a label set (from capabilities.roll_off_filters etc.)
|
|
43
|
+
* 'int' : raw integer
|
|
44
|
+
*/
|
|
45
|
+
const PARAMS = {
|
|
46
|
+
attenuation: {
|
|
47
|
+
section: 'outs', unit: 'tenths-db', confidence: 'confirmed',
|
|
48
|
+
capInfo: 'attenuation_info',
|
|
49
|
+
build: (v) => ({ attenuation: v }) // v already in tenths
|
|
50
|
+
},
|
|
51
|
+
// All four below CONFIRMED on hardware (fw 1.9.0b62872) via an active set->re-read:
|
|
52
|
+
// the `.custom.outs` set shape actually applies for each. See scripts/set-probe.js.
|
|
53
|
+
mute: {
|
|
54
|
+
section: 'outs', unit: 'bool', confidence: 'confirmed',
|
|
55
|
+
capFlag: 'mute',
|
|
56
|
+
build: (v) => ({ mute: !!v })
|
|
57
|
+
},
|
|
58
|
+
roll_off_filter: {
|
|
59
|
+
section: 'outs', unit: 'enum', confidence: 'confirmed',
|
|
60
|
+
capEnum: 'roll_off_filters',
|
|
61
|
+
build: (v) => ({ roll_off_filter: v | 0 })
|
|
62
|
+
},
|
|
63
|
+
// out_max_level is a 0/1 toggle between the card's two output reference levels.
|
|
64
|
+
out_max_level: {
|
|
65
|
+
section: 'outs', unit: 'int', confidence: 'confirmed',
|
|
66
|
+
capFlag: 'out_max_level',
|
|
67
|
+
build: (v) => ({ out_max_level: v ? 1 : 0 })
|
|
68
|
+
},
|
|
69
|
+
// Per-channel trim is an array within custom.outs; the partial-array shape (target
|
|
70
|
+
// channel carries {trim}, others {}) is confirmed to apply on the device.
|
|
71
|
+
channel_trim: {
|
|
72
|
+
section: 'outs', unit: 'tenths-db', confidence: 'confirmed',
|
|
73
|
+
capInfo: 'channel.trim_info', perChannel: true,
|
|
74
|
+
build: (v, ctx) => {
|
|
75
|
+
// ctx: { channelCount, channelIndex }
|
|
76
|
+
const arr = [];
|
|
77
|
+
for (let i = 0; i < (ctx.channelCount || 1); i++) {
|
|
78
|
+
arr.push(i === ctx.channelIndex ? { trim: v } : {});
|
|
79
|
+
}
|
|
80
|
+
return { channels: arr };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** dB -> device tenths (rounded). */
|
|
86
|
+
function dbToTenths(db) { return Math.round(db * 10); }
|
|
87
|
+
/** device tenths -> dB. */
|
|
88
|
+
function tenthsToDb(t) { return Math.round(t) / 10; }
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
moduleOutsPath,
|
|
92
|
+
moduleInsPath,
|
|
93
|
+
moduleOutsPathRegex,
|
|
94
|
+
PARAMS,
|
|
95
|
+
dbToTenths,
|
|
96
|
+
tenthsToDb
|
|
97
|
+
};
|