@smartspectra/node-sdk 3.2.0-rc.6
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 +13 -0
- package/README.md +409 -0
- package/js/constants.js +125 -0
- package/js/ffi.js +494 -0
- package/js/index.d.ts +282 -0
- package/js/index.js +60 -0
- package/js/main/index.d.ts +32 -0
- package/js/main/index.js +404 -0
- package/js/messages/generated.d.ts +2083 -0
- package/js/messages/generated.js +5810 -0
- package/js/messages/index.d.ts +27 -0
- package/js/messages/index.js +67 -0
- package/js/preload/index.js +116 -0
- package/js/renderer/index.d.ts +154 -0
- package/js/renderer/index.js +670 -0
- package/js/resolve-native.js +113 -0
- package/js/smartspectra.js +293 -0
- package/package.json +81 -0
package/js/main/index.js
ADDED
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
// js/main/index.js
|
|
2
|
+
// Copyright (C) 2026 Presage Technologies, Inc.
|
|
3
|
+
//
|
|
4
|
+
// SPDX-License-Identifier: LicenseRef-Proprietary
|
|
5
|
+
//
|
|
6
|
+
// Main-process IPC glue for `@smartspectra/node-sdk/renderer`. The renderer
|
|
7
|
+
// owns a `MediaStream` and pumps decoded RGBA frames over a MessagePort
|
|
8
|
+
// created by the preload bridge; this module instantiates a
|
|
9
|
+
// `SmartSpectraSDK`, dispatches frames into `sendFrame()`, and forwards
|
|
10
|
+
// SDK callbacks back through the same port.
|
|
11
|
+
|
|
12
|
+
'use strict';
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { ipcMain, app } = require('electron');
|
|
17
|
+
|
|
18
|
+
const {
|
|
19
|
+
SmartSpectraSDK,
|
|
20
|
+
ProcessingStatus,
|
|
21
|
+
} = require('../smartspectra');
|
|
22
|
+
|
|
23
|
+
const ATTACH_CHANNEL = 'smartspectra:attach';
|
|
24
|
+
|
|
25
|
+
let preconfigured = false;
|
|
26
|
+
|
|
27
|
+
// Process-wide barrier serializing native session teardown against the next
|
|
28
|
+
// session start. SDK state is process-global, so two SmartSpectra sessions
|
|
29
|
+
// must never run concurrently. SmartSpectraSDK.destroy() defers native
|
|
30
|
+
// teardown while a stopAsync() drains on a koffi worker thread, so a
|
|
31
|
+
// configure/reload arriving mid-drain could otherwise stand up a new session
|
|
32
|
+
// before the old one finishes winding down — including across a renderer
|
|
33
|
+
// reload, where the replacement is a fresh connection that can't see the prior
|
|
34
|
+
// connection's pending teardown. Every session start awaits this barrier;
|
|
35
|
+
// every teardown folds itself in. (Process-scoped, not per-connection, for the
|
|
36
|
+
// reload case.)
|
|
37
|
+
let sessionTeardown = Promise.resolve();
|
|
38
|
+
|
|
39
|
+
// Tear `sdk` down (no-op if null) and fold its possibly-deferred native
|
|
40
|
+
// teardown into the barrier so the next start waits for it. Never rejects.
|
|
41
|
+
function retireSession(sdk) {
|
|
42
|
+
if (!sdk) return;
|
|
43
|
+
let done;
|
|
44
|
+
try { done = sdk.destroy(); }
|
|
45
|
+
catch (e) { done = Promise.reject(e); }
|
|
46
|
+
sessionTeardown = Promise.allSettled([sessionTeardown, Promise.resolve(done)])
|
|
47
|
+
.then(() => {});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Per-host base directory the SDK initializes against. Namespaced under
|
|
51
|
+
// `app.getName()` to avoid collisions when multiple Electron hosts share
|
|
52
|
+
// the OS-level base (e.g. unpackaged `electron .` dev runs on macOS).
|
|
53
|
+
function defaultSdkDirectory() {
|
|
54
|
+
return path.join(app.getPath('cache'), app.getName(), 'SmartSpectraSDK');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Idempotent one-time SDK initialization. Not exported.
|
|
58
|
+
function ensurePreconfigured() {
|
|
59
|
+
if (preconfigured) return;
|
|
60
|
+
const { preconfigure: capiPreconfigure } = require('../ffi');
|
|
61
|
+
const sdkDirectory = defaultSdkDirectory();
|
|
62
|
+
// Pre-create the tree with restrictive permissions; Electron's
|
|
63
|
+
// app.getPath('cache') root isn't always present on first launch.
|
|
64
|
+
try {
|
|
65
|
+
fs.mkdirSync(sdkDirectory, { recursive: true, mode: 0o700 });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
`SmartSpectra: failed to prepare SDK directory ${sdkDirectory}: ` +
|
|
69
|
+
`${err.message || err}. Some metrics (arterial pressure, EDA, ` +
|
|
70
|
+
'micromotion) need this to initialize; other metrics will still ' +
|
|
71
|
+
'work.');
|
|
72
|
+
}
|
|
73
|
+
if (process.env.SMARTSPECTRA_DIAGNOSTICS === '1') {
|
|
74
|
+
console.log(`[smartspectra/main] preconfigure invoked`);
|
|
75
|
+
}
|
|
76
|
+
capiPreconfigure(sdkDirectory);
|
|
77
|
+
preconfigured = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const defaultLogger = (level, msg) => {
|
|
81
|
+
if (level === 'error') console.error(`[smartspectra/main] ${msg}`);
|
|
82
|
+
else if (level === 'warn') console.warn(`[smartspectra/main] ${msg}`);
|
|
83
|
+
else console.log(`[smartspectra/main] ${msg}`);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// The main process treats every IPC payload as untrusted: a BrowserWindow that
|
|
87
|
+
// later loads remote content (or any frame inside the renderer) could post
|
|
88
|
+
// messages on the bridge. Clamp the fields we forward into native code so a
|
|
89
|
+
// hostile shape can't cause a multi-GB allocation in koffi or trip the SDK's
|
|
90
|
+
// own sanity checks with an out-of-range value.
|
|
91
|
+
const MAX_API_KEY_BYTES = 1024;
|
|
92
|
+
const MAX_REQUESTED_METRICS = 128; // current MetricType enum has ~30 values
|
|
93
|
+
const MAX_INSIGHT_TEXT_BYTES = 8192;
|
|
94
|
+
const MAX_FRAME_DIM = 8192; // 8K resolution headroom
|
|
95
|
+
const MAX_FRAME_BUFFER_BYTES = 256 * 1024 * 1024; // 256 MiB — covers 8K RGBA + slack
|
|
96
|
+
const SAFE_INT32 = 2147483647;
|
|
97
|
+
|
|
98
|
+
function sanitizeOptions(raw) {
|
|
99
|
+
const opts = (raw && typeof raw === 'object') ? raw : {};
|
|
100
|
+
const out = {};
|
|
101
|
+
|
|
102
|
+
if (typeof opts.apiKey === 'string') {
|
|
103
|
+
out.apiKey = opts.apiKey.length > MAX_API_KEY_BYTES
|
|
104
|
+
? opts.apiKey.slice(0, MAX_API_KEY_BYTES)
|
|
105
|
+
: opts.apiKey;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (Array.isArray(opts.requestedMetrics)) {
|
|
109
|
+
const src = opts.requestedMetrics.length > MAX_REQUESTED_METRICS
|
|
110
|
+
? opts.requestedMetrics.slice(0, MAX_REQUESTED_METRICS)
|
|
111
|
+
: opts.requestedMetrics;
|
|
112
|
+
const cleaned = [];
|
|
113
|
+
for (const v of src) {
|
|
114
|
+
if (typeof v === 'number' && Number.isFinite(v) &&
|
|
115
|
+
v >= -SAFE_INT32 - 1 && v <= SAFE_INT32) {
|
|
116
|
+
cleaned.push(v | 0);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (cleaned.length > 0) out.requestedMetrics = cleaned;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
out.enableAccumulatedOutput = !!opts.enableAccumulatedOutput;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sanitizeInsightText(raw) {
|
|
127
|
+
if (typeof raw !== 'string') {
|
|
128
|
+
throw new TypeError('requestInsight: text must be a string');
|
|
129
|
+
}
|
|
130
|
+
if (raw.length > MAX_INSIGHT_TEXT_BYTES) {
|
|
131
|
+
throw new RangeError(
|
|
132
|
+
`requestInsight: text length ${raw.length} exceeds ${MAX_INSIGHT_TEXT_BYTES}`);
|
|
133
|
+
}
|
|
134
|
+
return raw;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Wires the per-window IPC channel that backs `@smartspectra/node-sdk/renderer`.
|
|
139
|
+
*
|
|
140
|
+
* Call once per `BrowserWindow`. The binding tracks the window via
|
|
141
|
+
* `'closed'` and releases its SDK + port automatically.
|
|
142
|
+
*
|
|
143
|
+
* @param {import('electron').BrowserWindow} window
|
|
144
|
+
* @param {{ logger?: (level: 'info' | 'warn' | 'error', msg: string) => void }} [options]
|
|
145
|
+
*/
|
|
146
|
+
function bindSmartSpectraIpc(window, options = {}) {
|
|
147
|
+
const log = options.logger || defaultLogger;
|
|
148
|
+
const wc = window.webContents;
|
|
149
|
+
if (!wc) throw new TypeError('bindSmartSpectraIpc: window has no webContents');
|
|
150
|
+
|
|
151
|
+
// Best-effort: preconfigure failures don't block the bind — basic metrics
|
|
152
|
+
// still work.
|
|
153
|
+
try {
|
|
154
|
+
ensurePreconfigured();
|
|
155
|
+
} catch (err) {
|
|
156
|
+
log('warn', `preconfigure failed: ${err.message || err}`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const onAttach = (event, _payload) => {
|
|
160
|
+
if (event.sender !== wc) return; // multi-window safety
|
|
161
|
+
const port = event.ports && event.ports[0];
|
|
162
|
+
if (!port) {
|
|
163
|
+
log('warn', 'attach message received without a MessagePort');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
handleConnection(port, log);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
ipcMain.on(ATTACH_CHANNEL, onAttach);
|
|
170
|
+
window.once('closed', () => {
|
|
171
|
+
ipcMain.removeListener(ATTACH_CHANNEL, onAttach);
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Handle a single renderer connection. Owns one SDK instance for the port's
|
|
176
|
+
// lifetime; the port stays open until the renderer closes it or the window
|
|
177
|
+
// navigates / unloads.
|
|
178
|
+
function handleConnection(port, log) {
|
|
179
|
+
let sdk = null;
|
|
180
|
+
let framesReceived = 0;
|
|
181
|
+
let framesDropped = 0;
|
|
182
|
+
const diagnostics = process.env.SMARTSPECTRA_DIAGNOSTICS === '1';
|
|
183
|
+
const FRAME_LOG_INTERVAL = 30; // ~once per second at 30 fps
|
|
184
|
+
|
|
185
|
+
const safePost = (msg) => {
|
|
186
|
+
try { port.postMessage(msg); }
|
|
187
|
+
catch (e) { log('warn', `port.postMessage failed: ${e.message}`); }
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// Buffer-bearing messages go through structured clone, not transfer:
|
|
191
|
+
// Electron's `MessagePortMain.postMessage` rejects ArrayBuffers in the
|
|
192
|
+
// transfer list with "Port at index 0 is not a valid port". The clone
|
|
193
|
+
// cost on the protobuf payload (~few KB per packet) is negligible.
|
|
194
|
+
const installCallbacks = () => {
|
|
195
|
+
sdk.on('processingStatus', (status) =>
|
|
196
|
+
safePost({ kind: 'processingStatus', status }));
|
|
197
|
+
sdk.on('validationStatus', (code, ts, hint) =>
|
|
198
|
+
safePost({ kind: 'validationStatus', code, timestampUs: ts, hint }));
|
|
199
|
+
sdk.on('metrics', (buf, ts) =>
|
|
200
|
+
safePost({ kind: 'metrics', buffer: toArrayBufferPayload(buf), timestampUs: ts }));
|
|
201
|
+
sdk.on('accumulatedMetrics', (buf, ts) =>
|
|
202
|
+
safePost({ kind: 'accumulatedMetrics', buffer: toArrayBufferPayload(buf), timestampUs: ts }));
|
|
203
|
+
sdk.on('insight', (buf, requestId) =>
|
|
204
|
+
safePost({ kind: 'insight', buffer: toArrayBufferPayload(buf), requestId }));
|
|
205
|
+
sdk.on('frameSentThrough', (sent, ts) =>
|
|
206
|
+
safePost({ kind: 'frameSentThrough', sent, timestampUs: ts }));
|
|
207
|
+
sdk.on('error', (code, message, retryable) =>
|
|
208
|
+
safePost({ kind: 'error', code, message, retryable }));
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
// start/stop/reset acks carry no payload; requestInsight carries
|
|
212
|
+
// `{ requestId }`.
|
|
213
|
+
const ackOk = (replyId, payload) =>
|
|
214
|
+
safePost({ kind: 'ack', replyId, ok: true, payload: payload || null });
|
|
215
|
+
const ackFail = (replyId, e) => safePost({
|
|
216
|
+
kind: 'ack',
|
|
217
|
+
replyId,
|
|
218
|
+
ok: false,
|
|
219
|
+
error: {
|
|
220
|
+
code: typeof e.code === 'number' ? e.code : 0,
|
|
221
|
+
message: e.message || String(e),
|
|
222
|
+
retryable: !!e.retryable,
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
port.on('message', (event) => {
|
|
227
|
+
const data = event && event.data;
|
|
228
|
+
if (diagnostics && (!data || typeof data.kind !== 'string')) {
|
|
229
|
+
log('warn', `dropped malformed port message: ` +
|
|
230
|
+
`data=${data === undefined ? 'undefined' : data === null ? 'null' : typeof data} ` +
|
|
231
|
+
`keys=${data && typeof data === 'object' ? Object.keys(data).join(',') : '-'}`);
|
|
232
|
+
}
|
|
233
|
+
if (!data || typeof data.kind !== 'string') return;
|
|
234
|
+
if (diagnostics && data.kind !== 'frame') {
|
|
235
|
+
// Frame messages have their own throttled counter below.
|
|
236
|
+
log('info', `<- ${data.kind}` +
|
|
237
|
+
(data.replyId !== undefined ? ` (replyId=${data.replyId})` : ''));
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
switch (data.kind) {
|
|
241
|
+
case 'configure':
|
|
242
|
+
// Retire the prior session (folding its teardown into the
|
|
243
|
+
// barrier) before standing up the replacement. The new SDK
|
|
244
|
+
// is lazy — no native session exists until start(), which
|
|
245
|
+
// awaits the barrier — so the two never overlap natively.
|
|
246
|
+
retireSession(sdk);
|
|
247
|
+
sdk = new SmartSpectraSDK(sanitizeOptions(data.options));
|
|
248
|
+
installCallbacks();
|
|
249
|
+
return;
|
|
250
|
+
|
|
251
|
+
case 'start': {
|
|
252
|
+
if (!sdk) throw new Error('start before configure');
|
|
253
|
+
const target = sdk;
|
|
254
|
+
const { replyId } = data;
|
|
255
|
+
const frameTransform = data.frameTransform | 0;
|
|
256
|
+
// Defer the native session creation until any prior
|
|
257
|
+
// session's teardown has fully drained (see sessionTeardown).
|
|
258
|
+
// Frames arriving in the meantime are dropped (no started
|
|
259
|
+
// session yet) — the same as the normal pre-start window.
|
|
260
|
+
sessionTeardown.then(() => {
|
|
261
|
+
if (sdk !== target) {
|
|
262
|
+
// A reconfigure/teardown superseded this start while
|
|
263
|
+
// we waited; starting the detached session would
|
|
264
|
+
// re-introduce the overlap we're guarding against.
|
|
265
|
+
ackFail(replyId, new Error('session replaced before start completed'));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
target.useCustomInput(frameTransform);
|
|
269
|
+
target.start();
|
|
270
|
+
ackOk(replyId);
|
|
271
|
+
}).catch((e) => ackFail(replyId, e));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
case 'stop':
|
|
276
|
+
// sdk.stop() blocks until the processing pipeline drains
|
|
277
|
+
// (can take multiple seconds). Run asynchronously so this
|
|
278
|
+
// IPC handler returns immediately and the event loop
|
|
279
|
+
// stays free.
|
|
280
|
+
if (sdk) {
|
|
281
|
+
sdk.stopAsync()
|
|
282
|
+
.then(() => ackOk(data.replyId))
|
|
283
|
+
.catch((e) => ackFail(data.replyId, e));
|
|
284
|
+
} else {
|
|
285
|
+
ackOk(data.replyId);
|
|
286
|
+
}
|
|
287
|
+
return;
|
|
288
|
+
|
|
289
|
+
case 'reset':
|
|
290
|
+
if (sdk) sdk.reset();
|
|
291
|
+
return ackOk(data.replyId);
|
|
292
|
+
|
|
293
|
+
case 'frame': {
|
|
294
|
+
if (!sdk) return;
|
|
295
|
+
const width = data.width | 0;
|
|
296
|
+
const height = data.height | 0;
|
|
297
|
+
if (width <= 0 || width > MAX_FRAME_DIM ||
|
|
298
|
+
height <= 0 || height > MAX_FRAME_DIM ||
|
|
299
|
+
!data.buffer ||
|
|
300
|
+
typeof data.buffer.byteLength !== 'number' ||
|
|
301
|
+
data.buffer.byteLength > MAX_FRAME_BUFFER_BYTES) {
|
|
302
|
+
framesDropped++;
|
|
303
|
+
if (framesDropped === 1 || framesDropped % FRAME_LOG_INTERVAL === 0) {
|
|
304
|
+
log('warn',
|
|
305
|
+
`dropped frame with out-of-range shape (${framesDropped} total)`);
|
|
306
|
+
}
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (diagnostics && framesReceived === 0) {
|
|
310
|
+
log('info', `received first frame ` +
|
|
311
|
+
`(${width}x${height} fmt=${data.pixelFormat} ` +
|
|
312
|
+
`bytes=${data.buffer.byteLength} ` +
|
|
313
|
+
`ts=${data.timestampUs})`);
|
|
314
|
+
}
|
|
315
|
+
// Frames carry no replyId, so a throw would otherwise be
|
|
316
|
+
// reported to the renderer as an 'error' event on every
|
|
317
|
+
// offending frame (30 fps). Transient ordering failures
|
|
318
|
+
// (non-monotonic timestamp, gap, post-stop) are expected
|
|
319
|
+
// during start/stop churn — drop the frame and log at most
|
|
320
|
+
// once per FRAME_LOG_INTERVAL instead of spamming 'error'.
|
|
321
|
+
try {
|
|
322
|
+
sdk.sendFrame(
|
|
323
|
+
Buffer.from(data.buffer),
|
|
324
|
+
width,
|
|
325
|
+
height,
|
|
326
|
+
data.stride | 0,
|
|
327
|
+
data.pixelFormat | 0,
|
|
328
|
+
data.timestampUs,
|
|
329
|
+
);
|
|
330
|
+
} catch (e) {
|
|
331
|
+
framesDropped++;
|
|
332
|
+
if (framesDropped === 1 || framesDropped % FRAME_LOG_INTERVAL === 0) {
|
|
333
|
+
log('warn', `dropped frame (${framesDropped} total): ` +
|
|
334
|
+
`${e.message || e}`);
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
framesReceived++;
|
|
339
|
+
if (diagnostics && framesReceived % FRAME_LOG_INTERVAL === 0) {
|
|
340
|
+
log('info', `received ${framesReceived} frames ` +
|
|
341
|
+
`(${data.width}x${data.height} fmt=${data.pixelFormat})`);
|
|
342
|
+
}
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
case 'requestInsight': {
|
|
347
|
+
if (!sdk) throw new Error('requestInsight before configure');
|
|
348
|
+
const id = sdk.requestInsight(sanitizeInsightText(data.text));
|
|
349
|
+
return ackOk(data.replyId, { requestId: id });
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
case 'destroy':
|
|
353
|
+
retireSession(sdk);
|
|
354
|
+
sdk = null;
|
|
355
|
+
try { port.close(); } catch { /* already closed */ }
|
|
356
|
+
return;
|
|
357
|
+
|
|
358
|
+
default:
|
|
359
|
+
log('warn', `unknown message kind: ${data.kind}`);
|
|
360
|
+
}
|
|
361
|
+
} catch (e) {
|
|
362
|
+
if (typeof data.replyId === 'number') {
|
|
363
|
+
ackFail(data.replyId, e);
|
|
364
|
+
} else {
|
|
365
|
+
safePost({
|
|
366
|
+
kind: 'error',
|
|
367
|
+
code: typeof e.code === 'number' ? e.code : 0,
|
|
368
|
+
message: e.message || String(e),
|
|
369
|
+
retryable: !!e.retryable,
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
port.on('close', () => {
|
|
376
|
+
// Fold teardown into the barrier so a reload's replacement session
|
|
377
|
+
// (a fresh connection) waits for this one to finish winding down.
|
|
378
|
+
retireSession(sdk);
|
|
379
|
+
sdk = null;
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
port.start();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Extract an ArrayBuffer from a Node Buffer for the structured-clone message
|
|
386
|
+
// payload (the port clones, it does not transfer — see installCallbacks).
|
|
387
|
+
// Returns the underlying ArrayBuffer directly when the Buffer spans it
|
|
388
|
+
// exactly; otherwise tightens a sliced view into its own ArrayBuffer.
|
|
389
|
+
function toArrayBufferPayload(buf) {
|
|
390
|
+
if (buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength) {
|
|
391
|
+
return buf.buffer;
|
|
392
|
+
}
|
|
393
|
+
// Sliced Buffer view — make a tight ArrayBuffer.
|
|
394
|
+
const ab = new ArrayBuffer(buf.byteLength);
|
|
395
|
+
new Uint8Array(ab).set(buf);
|
|
396
|
+
return ab;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
module.exports = {
|
|
400
|
+
bindSmartSpectraIpc,
|
|
401
|
+
// Re-exported for consumers driving the low-level surface directly.
|
|
402
|
+
SmartSpectraSDK,
|
|
403
|
+
ProcessingStatus,
|
|
404
|
+
};
|