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 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
+ };