dspx 1.2.3 → 1.3.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/README.md +40 -78
- package/binding.gyp +10 -0
- package/dist/FilterBankDesign.d.ts +233 -0
- package/dist/FilterBankDesign.d.ts.map +1 -0
- package/dist/FilterBankDesign.js +247 -0
- package/dist/FilterBankDesign.js.map +1 -0
- package/dist/advanced-dsp.d.ts +6 -6
- package/dist/advanced-dsp.d.ts.map +1 -1
- package/dist/advanced-dsp.js +35 -12
- package/dist/advanced-dsp.js.map +1 -1
- package/dist/backends.d.ts +0 -103
- package/dist/backends.d.ts.map +1 -1
- package/dist/backends.js +0 -217
- package/dist/backends.js.map +1 -1
- package/dist/bindings.d.ts +216 -17
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +503 -42
- package/dist/bindings.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/types.d.ts +67 -8
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +38 -8
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +84 -26
- package/dist/utils.js.map +1 -1
- package/package.json +1 -2
- package/prebuilds/win32-x64/dspx.node +0 -0
- package/scripts/add-dispose-to-tests.js +145 -0
- package/src/native/DspPipeline.cc +777 -143
- package/src/native/DspPipeline.h +13 -0
- package/src/native/FilterBankDesignBindings.cc +241 -0
- package/src/native/IDspStage.h +24 -0
- package/src/native/UtilityBindings.cc +130 -0
- package/src/native/adapters/ClipDetectionStage.h +15 -4
- package/src/native/adapters/ConvolutionStage.h +101 -0
- package/src/native/adapters/CumulativeMovingAverageStage.h +264 -0
- package/src/native/adapters/DecimatorStage.h +80 -0
- package/src/native/adapters/DifferentiatorStage.h +13 -0
- package/src/native/adapters/ExponentialMovingAverageStage.h +290 -0
- package/src/native/adapters/FilterBankStage.cc +336 -0
- package/src/native/adapters/FilterBankStage.h +170 -0
- package/src/native/adapters/FilterStage.cc +139 -1
- package/src/native/adapters/FilterStage.h +4 -0
- package/src/native/adapters/HilbertEnvelopeStage.h +55 -0
- package/src/native/adapters/IntegratorStage.h +15 -0
- package/src/native/adapters/InterpolatorStage.h +51 -0
- package/src/native/adapters/LinearRegressionStage.h +40 -0
- package/src/native/adapters/LmsStage.h +63 -0
- package/src/native/adapters/MeanAbsoluteValueStage.h +76 -0
- package/src/native/adapters/MovingAverageStage.h +119 -0
- package/src/native/adapters/PeakDetectionStage.h +53 -0
- package/src/native/adapters/RectifyStage.h +14 -0
- package/src/native/adapters/ResamplerStage.h +67 -0
- package/src/native/adapters/RlsStage.h +76 -0
- package/src/native/adapters/RmsStage.h +73 -1
- package/src/native/adapters/SnrStage.h +45 -0
- package/src/native/adapters/SscStage.h +65 -0
- package/src/native/adapters/StftStage.h +62 -0
- package/src/native/adapters/VarianceStage.h +60 -1
- package/src/native/adapters/WampStage.h +59 -0
- package/src/native/adapters/WaveformLengthStage.h +51 -0
- package/src/native/adapters/ZScoreNormalizeStage.h +65 -1
- package/src/native/core/CumulativeMovingAverageFilter.h +123 -0
- package/src/native/core/ExponentialMovingAverageFilter.h +129 -0
- package/src/native/core/FilterBankDesign.h +266 -0
- package/src/native/core/Policies.h +124 -0
- package/src/native/utils/CircularBufferArray.cc +2 -1
- package/src/native/utils/Toon.h +195 -0
package/dist/bindings.js
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { CircularLogBuffer } from "./CircularLogBuffer.js";
|
|
5
5
|
import { DriftDetector } from "./DriftDetector.js";
|
|
6
6
|
import { FirFilter, IirFilter, } from "./filters.js";
|
|
7
|
+
import { DspUtils } from "./utils.js";
|
|
7
8
|
// Get the directory of the current file
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = dirname(__filename);
|
|
@@ -166,6 +167,97 @@ class DspProcessor {
|
|
|
166
167
|
this.stages.push(`movingAverage:${params.mode}`);
|
|
167
168
|
return this;
|
|
168
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Add an Exponential Moving Average (EMA) stage to the pipeline
|
|
172
|
+
*
|
|
173
|
+
* EMA gives exponentially decaying weight to older samples, providing adaptive smoothing.
|
|
174
|
+
* Formula: EMA(t) = α * value(t) + (1 - α) * EMA(t-1)
|
|
175
|
+
*
|
|
176
|
+
* @param params - Configuration for the EMA filter
|
|
177
|
+
* @param params.mode - "batch" for stateless EMA (resets per chunk), "moving" for stateful with continuity
|
|
178
|
+
* @param params.alpha - Smoothing factor (0 < α ≤ 1)
|
|
179
|
+
* - α close to 1: Fast response to changes (less smoothing)
|
|
180
|
+
* - α close to 0: Slow response to changes (more smoothing)
|
|
181
|
+
* - From N-period SMA: α = 2 / (N + 1)
|
|
182
|
+
* @returns this instance for method chaining
|
|
183
|
+
*
|
|
184
|
+
* @example
|
|
185
|
+
* // Batch mode: Reset EMA for each process() call
|
|
186
|
+
* pipeline.ExponentialMovingAverage({ mode: "batch", alpha: 0.3 });
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* // Moving mode: EMA continues across process() calls
|
|
190
|
+
* pipeline.ExponentialMovingAverage({ mode: "moving", alpha: 0.1 });
|
|
191
|
+
*
|
|
192
|
+
* @example
|
|
193
|
+
* // Fast adaptive filter (equivalent to ~10-sample SMA)
|
|
194
|
+
* pipeline.ExponentialMovingAverage({ mode: "moving", alpha: 2 / (10 + 1) });
|
|
195
|
+
*
|
|
196
|
+
* @example
|
|
197
|
+
* // Chain with other stages for advanced processing
|
|
198
|
+
* pipeline
|
|
199
|
+
* .ExponentialMovingAverage({ mode: "moving", alpha: 0.2 })
|
|
200
|
+
* .Rectify({ mode: "full" })
|
|
201
|
+
* .Rms({ mode: "moving", windowSize: 50 });
|
|
202
|
+
*/
|
|
203
|
+
ExponentialMovingAverage(params) {
|
|
204
|
+
// Validate alpha parameter
|
|
205
|
+
if (params.alpha === undefined) {
|
|
206
|
+
throw new TypeError(`ExponentialMovingAverage: alpha is required`);
|
|
207
|
+
}
|
|
208
|
+
if (params.alpha <= 0 || params.alpha > 1) {
|
|
209
|
+
throw new TypeError(`ExponentialMovingAverage: alpha must be in range (0, 1], got ${params.alpha}`);
|
|
210
|
+
}
|
|
211
|
+
this.nativeInstance.addStage("exponentialMovingAverage", params);
|
|
212
|
+
this.stages.push(`exponentialMovingAverage:${params.mode}:α=${params.alpha}`);
|
|
213
|
+
return this;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Add a Cumulative Moving Average (CMA) stage to the pipeline
|
|
217
|
+
*
|
|
218
|
+
* CMA is the average of ALL samples seen since initialization (not just a window).
|
|
219
|
+
* Formula: CMA(n) = (sum of all values) / n
|
|
220
|
+
*
|
|
221
|
+
* Unlike simple moving average (SMA), CMA considers entire history:
|
|
222
|
+
* - SMA: Uses fixed window of recent N samples
|
|
223
|
+
* - CMA: Uses all samples from start to current
|
|
224
|
+
*
|
|
225
|
+
* Memory-efficient: only stores running sum and count.
|
|
226
|
+
*
|
|
227
|
+
* @param params - Configuration for the CMA filter
|
|
228
|
+
* @param params.mode - "batch" for stateless CMA (resets per chunk), "moving" for stateful with continuity
|
|
229
|
+
* @returns this instance for method chaining
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* // Batch mode: CMA resets for each process() call
|
|
233
|
+
* pipeline.CumulativeMovingAverage({ mode: "batch" });
|
|
234
|
+
*
|
|
235
|
+
* @example
|
|
236
|
+
* // Moving mode: CMA accumulates across all process() calls
|
|
237
|
+
* pipeline.CumulativeMovingAverage({ mode: "moving" });
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* // Use case: Long-term baseline estimation from calibration data
|
|
241
|
+
* const calibrationPipeline = createDspPipeline();
|
|
242
|
+
* calibrationPipeline.CumulativeMovingAverage({ mode: "moving" });
|
|
243
|
+
*
|
|
244
|
+
* // Process calibration chunks
|
|
245
|
+
* await calibrationPipeline.process(chunk1, { channels: 1 });
|
|
246
|
+
* await calibrationPipeline.process(chunk2, { channels: 1 });
|
|
247
|
+
* await calibrationPipeline.process(chunk3, { channels: 1 });
|
|
248
|
+
* // Final output contains cumulative average across all chunks
|
|
249
|
+
*
|
|
250
|
+
* @example
|
|
251
|
+
* // Chain with variance for online statistics
|
|
252
|
+
* pipeline
|
|
253
|
+
* .CumulativeMovingAverage({ mode: "moving" })
|
|
254
|
+
* .tap((samples) => console.log('Running mean:', samples[samples.length - 1]));
|
|
255
|
+
*/
|
|
256
|
+
CumulativeMovingAverage(params) {
|
|
257
|
+
this.nativeInstance.addStage("cumulativeMovingAverage", params);
|
|
258
|
+
this.stages.push(`cumulativeMovingAverage:${params.mode}`);
|
|
259
|
+
return this;
|
|
260
|
+
}
|
|
169
261
|
/**
|
|
170
262
|
* Add a RMS (root mean square) stage to the pipeline
|
|
171
263
|
* @param params - Configuration for the RMS filter
|
|
@@ -1244,6 +1336,70 @@ class DspProcessor {
|
|
|
1244
1336
|
this.stages.push(`channelMerge:${params.numInputChannels}ch→${params.mapping.length}ch`);
|
|
1245
1337
|
return this;
|
|
1246
1338
|
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Apply a bank of IIR filters to split each input channel into multiple frequency bands.
|
|
1341
|
+
*
|
|
1342
|
+
* Implements frequency decomposition for:
|
|
1343
|
+
* - Speech recognition: Mel-scale filter banks (20-40 bands)
|
|
1344
|
+
* - Audio compression: Bark-scale psychoacoustic analysis
|
|
1345
|
+
* - Musical analysis: Octave bands (log scale)
|
|
1346
|
+
* - Research: Linear-scale frequency analysis
|
|
1347
|
+
*
|
|
1348
|
+
* Architecture:
|
|
1349
|
+
* - Input: N channels (interleaved)
|
|
1350
|
+
* - Output: N × M channels (interleaved, channel-major layout)
|
|
1351
|
+
* - Layout: All bands for channel 1, then all bands for channel 2, etc.
|
|
1352
|
+
*
|
|
1353
|
+
* @param params - Filter bank parameters
|
|
1354
|
+
* @param params.definitions - Array of filter definitions (one per band)
|
|
1355
|
+
* @param params.inputChannels - Number of input channels
|
|
1356
|
+
* @returns this instance for method chaining
|
|
1357
|
+
*
|
|
1358
|
+
* @throws {TypeError} If definitions or inputChannels are missing
|
|
1359
|
+
* @throws {RangeError} If inputChannels <= 0 or definitions is empty
|
|
1360
|
+
*
|
|
1361
|
+
* @example
|
|
1362
|
+
* // Design a 24-band Mel-scale filter bank for speech
|
|
1363
|
+
* import { FilterBankDesign } from 'dspx';
|
|
1364
|
+
*
|
|
1365
|
+
* const melBank = FilterBankDesign.createMel(24, 16000, [100, 8000]);
|
|
1366
|
+
* pipeline.FilterBank({
|
|
1367
|
+
* definitions: melBank,
|
|
1368
|
+
* inputChannels: 1
|
|
1369
|
+
* });
|
|
1370
|
+
* // Output: 24 channels (one per Mel band)
|
|
1371
|
+
*
|
|
1372
|
+
* @example
|
|
1373
|
+
* // Custom 10-band octave filter bank for stereo input
|
|
1374
|
+
* const octaveBank = FilterBankDesign.createLog(10, 44100, [20, 20000]);
|
|
1375
|
+
* pipeline.FilterBank({
|
|
1376
|
+
* definitions: octaveBank,
|
|
1377
|
+
* inputChannels: 2
|
|
1378
|
+
* });
|
|
1379
|
+
* // Output: 20 channels (10 bands × 2 input channels)
|
|
1380
|
+
*/
|
|
1381
|
+
FilterBank(params) {
|
|
1382
|
+
if (!Array.isArray(params.definitions) || params.definitions.length === 0) {
|
|
1383
|
+
throw new TypeError("FilterBank: definitions must be a non-empty array");
|
|
1384
|
+
}
|
|
1385
|
+
if (!Number.isInteger(params.inputChannels) || params.inputChannels <= 0) {
|
|
1386
|
+
throw new RangeError("FilterBank: inputChannels must be a positive integer");
|
|
1387
|
+
}
|
|
1388
|
+
// Validate each filter definition
|
|
1389
|
+
for (let i = 0; i < params.definitions.length; i++) {
|
|
1390
|
+
const def = params.definitions[i];
|
|
1391
|
+
if (!Array.isArray(def.b) || def.b.length === 0) {
|
|
1392
|
+
throw new TypeError(`FilterBank: definition[${i}].b must be a non-empty array`);
|
|
1393
|
+
}
|
|
1394
|
+
if (!Array.isArray(def.a) || def.a.length === 0) {
|
|
1395
|
+
throw new TypeError(`FilterBank: definition[${i}].a must be a non-empty array`);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
this.nativeInstance.addStage("filterBank", params);
|
|
1399
|
+
const outputChannels = params.inputChannels * params.definitions.length;
|
|
1400
|
+
this.stages.push(`filterBank:${params.definitions.length}bands×${params.inputChannels}ch=${outputChannels}ch`);
|
|
1401
|
+
return this;
|
|
1402
|
+
}
|
|
1247
1403
|
/**
|
|
1248
1404
|
* Detect clipping (signal saturation) in the input stream.
|
|
1249
1405
|
*
|
|
@@ -2230,6 +2386,16 @@ class DspProcessor {
|
|
|
2230
2386
|
* });
|
|
2231
2387
|
*
|
|
2232
2388
|
* @example
|
|
2389
|
+
* // Generic IIR filter (uses Butterworth internally)
|
|
2390
|
+
* pipeline.filter({
|
|
2391
|
+
* type: "iir",
|
|
2392
|
+
* mode: "highpass",
|
|
2393
|
+
* cutoffFrequency: 500,
|
|
2394
|
+
* sampleRate: 8000,
|
|
2395
|
+
* order: 2
|
|
2396
|
+
* });
|
|
2397
|
+
*
|
|
2398
|
+
* @example
|
|
2233
2399
|
* // Butterworth band-pass filter
|
|
2234
2400
|
* pipeline.filter({
|
|
2235
2401
|
* type: "butterworth",
|
|
@@ -2292,8 +2458,10 @@ class DspProcessor {
|
|
|
2292
2458
|
filterInstance = this.createBiquadFilter(options);
|
|
2293
2459
|
break;
|
|
2294
2460
|
case "iir":
|
|
2461
|
+
filterInstance = this.createIirFilter(options);
|
|
2462
|
+
break;
|
|
2295
2463
|
default:
|
|
2296
|
-
throw new Error(`Filter type
|
|
2464
|
+
throw new Error(`Filter type not supported. Valid types: 'fir', 'iir', 'butterworth', 'chebyshev', 'biquad'`);
|
|
2297
2465
|
}
|
|
2298
2466
|
}
|
|
2299
2467
|
catch (error) {
|
|
@@ -2438,6 +2606,76 @@ class DspProcessor {
|
|
|
2438
2606
|
throw new Error(`Unsupported Chebyshev filter mode: ${mode}`);
|
|
2439
2607
|
}
|
|
2440
2608
|
}
|
|
2609
|
+
/**
|
|
2610
|
+
* Helper to create generic IIR filter from options
|
|
2611
|
+
* Uses first-order filters for order=1, Butterworth otherwise
|
|
2612
|
+
*/
|
|
2613
|
+
createIirFilter(options) {
|
|
2614
|
+
const { mode, cutoffFrequency, lowCutoffFrequency, highCutoffFrequency, order, } = options;
|
|
2615
|
+
// Validate order
|
|
2616
|
+
if (!order || order < 1) {
|
|
2617
|
+
throw new Error("IIR filter requires order >= 1");
|
|
2618
|
+
}
|
|
2619
|
+
// For first-order filters, use optimized implementations
|
|
2620
|
+
if (order === 1) {
|
|
2621
|
+
switch (mode) {
|
|
2622
|
+
case "lowpass":
|
|
2623
|
+
if (!cutoffFrequency) {
|
|
2624
|
+
throw new Error("cutoffFrequency required for lowpass filter");
|
|
2625
|
+
}
|
|
2626
|
+
return IirFilter.createFirstOrderLowPass({
|
|
2627
|
+
cutoffFrequency,
|
|
2628
|
+
sampleRate: options.sampleRate,
|
|
2629
|
+
});
|
|
2630
|
+
case "highpass":
|
|
2631
|
+
if (!cutoffFrequency) {
|
|
2632
|
+
throw new Error("cutoffFrequency required for highpass filter");
|
|
2633
|
+
}
|
|
2634
|
+
return IirFilter.createFirstOrderHighPass({
|
|
2635
|
+
cutoffFrequency,
|
|
2636
|
+
sampleRate: options.sampleRate,
|
|
2637
|
+
});
|
|
2638
|
+
default:
|
|
2639
|
+
throw new Error(`First-order IIR filter only supports lowpass and highpass modes. For ${mode}, use order > 1 or a different filter type.`);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
// For higher-order filters, delegate to Butterworth (maximally flat response)
|
|
2643
|
+
switch (mode) {
|
|
2644
|
+
case "lowpass":
|
|
2645
|
+
if (!cutoffFrequency) {
|
|
2646
|
+
throw new Error("cutoffFrequency required for lowpass filter");
|
|
2647
|
+
}
|
|
2648
|
+
return IirFilter.createButterworthLowPass({
|
|
2649
|
+
cutoffFrequency,
|
|
2650
|
+
sampleRate: options.sampleRate,
|
|
2651
|
+
order,
|
|
2652
|
+
});
|
|
2653
|
+
case "highpass":
|
|
2654
|
+
if (!cutoffFrequency) {
|
|
2655
|
+
throw new Error("cutoffFrequency required for highpass filter");
|
|
2656
|
+
}
|
|
2657
|
+
return IirFilter.createButterworthHighPass({
|
|
2658
|
+
cutoffFrequency,
|
|
2659
|
+
sampleRate: options.sampleRate,
|
|
2660
|
+
order,
|
|
2661
|
+
});
|
|
2662
|
+
case "bandpass":
|
|
2663
|
+
if (!lowCutoffFrequency || !highCutoffFrequency) {
|
|
2664
|
+
throw new Error("lowCutoffFrequency and highCutoffFrequency required for bandpass filter");
|
|
2665
|
+
}
|
|
2666
|
+
return IirFilter.createButterworthBandPass({
|
|
2667
|
+
lowCutoffFrequency,
|
|
2668
|
+
highCutoffFrequency,
|
|
2669
|
+
sampleRate: options.sampleRate,
|
|
2670
|
+
order,
|
|
2671
|
+
});
|
|
2672
|
+
case "bandstop":
|
|
2673
|
+
case "notch":
|
|
2674
|
+
throw new Error(`IIR bandstop/notch filters not yet implemented. Use 'butterworth' or 'chebyshev' type instead, or use biquad notch filter.`);
|
|
2675
|
+
default:
|
|
2676
|
+
throw new Error(`Unsupported IIR filter mode: ${mode}`);
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2441
2679
|
/**
|
|
2442
2680
|
* Helper to create Biquad filter from options
|
|
2443
2681
|
*/
|
|
@@ -2552,38 +2790,42 @@ class DspProcessor {
|
|
|
2552
2790
|
* @returns Promise that resolves to the processed Float32Array (same reference as input)
|
|
2553
2791
|
*/
|
|
2554
2792
|
async process(input, timestampsOrOptions, optionsIfTimestamps) {
|
|
2793
|
+
let bufferToProcess;
|
|
2794
|
+
let inferredChannels;
|
|
2795
|
+
if (Array.isArray(input)) {
|
|
2796
|
+
inferredChannels = input.length;
|
|
2797
|
+
bufferToProcess = DspUtils.interleave(input);
|
|
2798
|
+
}
|
|
2799
|
+
else {
|
|
2800
|
+
bufferToProcess = input;
|
|
2801
|
+
}
|
|
2555
2802
|
let timestamps;
|
|
2556
2803
|
let options;
|
|
2804
|
+
if (!(bufferToProcess instanceof Float32Array)) {
|
|
2805
|
+
throw new TypeError(`Input samples must be a Float32Array, got ${typeof input}`);
|
|
2806
|
+
}
|
|
2557
2807
|
// Detect which overload was called
|
|
2558
2808
|
if (timestampsOrOptions instanceof Float32Array) {
|
|
2559
2809
|
// Time-based mode: process(samples, timestamps, options)
|
|
2560
2810
|
timestamps = timestampsOrOptions;
|
|
2561
2811
|
options = { channels: 1, ...optionsIfTimestamps };
|
|
2562
|
-
if (timestamps.length !==
|
|
2563
|
-
throw new Error(`Timestamps length (${timestamps.length}) must match samples length (${
|
|
2812
|
+
if (timestamps.length !== bufferToProcess.length) {
|
|
2813
|
+
throw new Error(`Timestamps length (${timestamps.length}) must match samples length (${bufferToProcess.length})`);
|
|
2564
2814
|
}
|
|
2565
2815
|
}
|
|
2566
2816
|
else {
|
|
2567
|
-
// Sample-based mode
|
|
2568
|
-
options
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
}
|
|
2576
|
-
}
|
|
2577
|
-
else {
|
|
2578
|
-
// Auto-generate sequential timestamps [0, 1, 2, ...]
|
|
2579
|
-
timestamps = new Float32Array(input.length);
|
|
2580
|
-
for (let i = 0; i < input.length; i++) {
|
|
2581
|
-
timestamps[i] = i;
|
|
2582
|
-
}
|
|
2583
|
-
}
|
|
2817
|
+
// Sample-based mode: process(samples, options)
|
|
2818
|
+
// Fix: ensure options is correctly formed even if arg is undefined/null
|
|
2819
|
+
options = { channels: 1, ...(timestampsOrOptions || {}) };
|
|
2820
|
+
// Optimization: Pass undefined timestamps, let C++ generate them
|
|
2821
|
+
}
|
|
2822
|
+
// If using planar input, ensure channels option is set correctly
|
|
2823
|
+
if (inferredChannels && options.channels === 1) {
|
|
2824
|
+
options.channels = inferredChannels;
|
|
2584
2825
|
}
|
|
2585
2826
|
const startTime = performance.now();
|
|
2586
2827
|
// Initialize drift detection if enabled
|
|
2828
|
+
// Note: If timestamps are implicit (undefined), drift is 0 by definition, so we skip this.
|
|
2587
2829
|
if (options.enableDriftDetection && timestamps && options.sampleRate) {
|
|
2588
2830
|
if (!this.driftDetector ||
|
|
2589
2831
|
this.driftDetector.getExpectedSampleRate() !== options.sampleRate) {
|
|
@@ -2600,14 +2842,22 @@ class DspProcessor {
|
|
|
2600
2842
|
try {
|
|
2601
2843
|
// Pool the start log
|
|
2602
2844
|
this.poolLog("debug", "Starting pipeline processing", {
|
|
2603
|
-
sampleCount:
|
|
2845
|
+
sampleCount: bufferToProcess.length,
|
|
2604
2846
|
channels: options.channels,
|
|
2605
2847
|
stages: this.stages.length,
|
|
2606
2848
|
mode: options.sampleRate ? "sample-based" : "time-based",
|
|
2607
2849
|
});
|
|
2608
2850
|
// Call native process with timestamps
|
|
2609
2851
|
// Note: The input buffer is modified in-place for zero-copy performance
|
|
2610
|
-
|
|
2852
|
+
let result;
|
|
2853
|
+
if (timestamps) {
|
|
2854
|
+
// Explicit timestamps provided
|
|
2855
|
+
result = await this.nativeInstance.process(bufferToProcess, timestamps, options);
|
|
2856
|
+
}
|
|
2857
|
+
else {
|
|
2858
|
+
// Implicit timestamps -> C++ generates them
|
|
2859
|
+
result = await this.nativeInstance.process(bufferToProcess, options);
|
|
2860
|
+
}
|
|
2611
2861
|
// Execute tap callbacks for debugging/inspection
|
|
2612
2862
|
if (this.tapCallbacks.length > 0) {
|
|
2613
2863
|
for (const { stageName, callback } of this.tapCallbacks) {
|
|
@@ -2673,10 +2923,10 @@ class DspProcessor {
|
|
|
2673
2923
|
}
|
|
2674
2924
|
}
|
|
2675
2925
|
/**
|
|
2676
|
-
* Process a copy of the
|
|
2926
|
+
* Process a copy of the input data through the DSP pipeline
|
|
2677
2927
|
* This method creates a copy of the input, so the original is preserved
|
|
2678
2928
|
*
|
|
2679
|
-
* @param input - Float32Array containing interleaved
|
|
2929
|
+
* @param input - Float32Array containing interleaved input samples (original is preserved)
|
|
2680
2930
|
* @param timestampsOrOptions - Either timestamps array or processing options (sample rate and channel count)
|
|
2681
2931
|
* @param optionsIfTimestamps - Processing options if timestamps were provided in second parameter
|
|
2682
2932
|
* @returns Promise that resolves to a new Float32Array with the processed data
|
|
@@ -2691,7 +2941,9 @@ class DspProcessor {
|
|
|
2691
2941
|
*/
|
|
2692
2942
|
async processCopy(input, timestampsOrOptions, optionsIfTimestamps) {
|
|
2693
2943
|
// Create a copy to preserve the original
|
|
2694
|
-
const copy =
|
|
2944
|
+
const copy = Array.isArray(input)
|
|
2945
|
+
? DspUtils.interleave(input)
|
|
2946
|
+
: new Float32Array(input);
|
|
2695
2947
|
// Handle both overloaded signatures by delegating to process()
|
|
2696
2948
|
if (timestampsOrOptions instanceof Float32Array) {
|
|
2697
2949
|
// Time-based mode: process(samples, timestamps, options)
|
|
@@ -2704,33 +2956,245 @@ class DspProcessor {
|
|
|
2704
2956
|
}
|
|
2705
2957
|
}
|
|
2706
2958
|
/**
|
|
2707
|
-
*
|
|
2708
|
-
*
|
|
2959
|
+
* Process data synchronously (BLOCKING).
|
|
2960
|
+
* * ⚠️ PERFORMANCE WARNING:
|
|
2961
|
+
* Only use this method inside Worker Threads or Cluster Workers.
|
|
2962
|
+
* Using this on the Main Thread of a server will block the Event Loop.
|
|
2963
|
+
* * This method bypasses the libuv thread pool, running DSP logic directly
|
|
2964
|
+
* on the calling thread. Ideal for high-frequency real-time audio loops.
|
|
2965
|
+
* * @param input - Float32Array containing samples (modified in-place if no resizing)
|
|
2966
|
+
* @param timestampsOrOptions - Timestamps array or options object
|
|
2967
|
+
* @param optionsIfTimestamps - Options object if timestamps provided
|
|
2968
|
+
*/
|
|
2969
|
+
processSync(input, timestampsOrOptions, optionsIfTimestamps) {
|
|
2970
|
+
let bufferToProcess;
|
|
2971
|
+
let inferredChannels;
|
|
2972
|
+
if (Array.isArray(input)) {
|
|
2973
|
+
inferredChannels = input.length;
|
|
2974
|
+
bufferToProcess = DspUtils.interleave(input);
|
|
2975
|
+
}
|
|
2976
|
+
else {
|
|
2977
|
+
bufferToProcess = input;
|
|
2978
|
+
}
|
|
2979
|
+
let timestamps;
|
|
2980
|
+
let options;
|
|
2981
|
+
if (!(bufferToProcess instanceof Float32Array)) {
|
|
2982
|
+
throw new TypeError(`Input samples must be a Float32Array, got ${typeof input}`);
|
|
2983
|
+
}
|
|
2984
|
+
// Detect which overload was called
|
|
2985
|
+
if (timestampsOrOptions instanceof Float32Array) {
|
|
2986
|
+
// Time-based mode: process(samples, timestamps, options)
|
|
2987
|
+
timestamps = timestampsOrOptions;
|
|
2988
|
+
options = { channels: 1, ...optionsIfTimestamps };
|
|
2989
|
+
if (timestamps.length !== bufferToProcess.length) {
|
|
2990
|
+
throw new Error(`Timestamps length (${timestamps.length}) must match samples length (${bufferToProcess.length})`);
|
|
2991
|
+
}
|
|
2992
|
+
}
|
|
2993
|
+
else {
|
|
2994
|
+
// Sample-based mode: process(samples, options)
|
|
2995
|
+
// Fix: ensure options is correctly formed even if arg is undefined/null
|
|
2996
|
+
options = { channels: 1, ...(timestampsOrOptions || {}) };
|
|
2997
|
+
// Optimization: Pass undefined timestamps, let C++ generate them
|
|
2998
|
+
}
|
|
2999
|
+
// If using planar input, ensure channels option is set correctly
|
|
3000
|
+
if (inferredChannels && options.channels === 1) {
|
|
3001
|
+
options.channels = inferredChannels;
|
|
3002
|
+
}
|
|
3003
|
+
const startTime = performance.now();
|
|
3004
|
+
// Initialize drift detection if enabled
|
|
3005
|
+
// Note: If timestamps are implicit (undefined), drift is 0 by definition, so we skip this.
|
|
3006
|
+
if (options.enableDriftDetection && timestamps && options.sampleRate) {
|
|
3007
|
+
if (!this.driftDetector ||
|
|
3008
|
+
this.driftDetector.getExpectedSampleRate() !== options.sampleRate) {
|
|
3009
|
+
// Create new detector if it doesn't exist or sample rate changed
|
|
3010
|
+
this.driftDetector = new DriftDetector({
|
|
3011
|
+
expectedSampleRate: options.sampleRate,
|
|
3012
|
+
driftThreshold: options.driftThreshold ?? 10,
|
|
3013
|
+
onDriftDetected: options.onDriftDetected,
|
|
3014
|
+
});
|
|
3015
|
+
}
|
|
3016
|
+
// Process timestamps to detect drift
|
|
3017
|
+
this.driftDetector.processBatch(timestamps);
|
|
3018
|
+
}
|
|
3019
|
+
try {
|
|
3020
|
+
// Pool the start log
|
|
3021
|
+
this.poolLog("debug", "Starting pipeline processing", {
|
|
3022
|
+
sampleCount: input.length,
|
|
3023
|
+
channels: options.channels,
|
|
3024
|
+
stages: this.stages.length,
|
|
3025
|
+
mode: options.sampleRate ? "sample-based" : "time-based",
|
|
3026
|
+
});
|
|
3027
|
+
// Call native process with timestamps
|
|
3028
|
+
// Note: The input buffer is modified in-place for zero-copy performance
|
|
3029
|
+
let result;
|
|
3030
|
+
if (timestamps) {
|
|
3031
|
+
// Explicit timestamps provided
|
|
3032
|
+
result = this.nativeInstance.processSync(bufferToProcess, timestamps, options);
|
|
3033
|
+
}
|
|
3034
|
+
else {
|
|
3035
|
+
// Implicit timestamps -> C++ generates them
|
|
3036
|
+
result = this.nativeInstance.processSync(bufferToProcess, options);
|
|
3037
|
+
}
|
|
3038
|
+
// Execute tap callbacks for debugging/inspection
|
|
3039
|
+
if (this.tapCallbacks.length > 0) {
|
|
3040
|
+
for (const { stageName, callback } of this.tapCallbacks) {
|
|
3041
|
+
try {
|
|
3042
|
+
callback(result, stageName);
|
|
3043
|
+
}
|
|
3044
|
+
catch (tapError) {
|
|
3045
|
+
// Don't let tap errors break the pipeline
|
|
3046
|
+
console.error(`Tap callback error at ${stageName}:`, tapError);
|
|
3047
|
+
}
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
3050
|
+
// Execute onBatch callback (efficient - one call per process)
|
|
3051
|
+
if (this.callbacks?.onBatch) {
|
|
3052
|
+
const stageName = this.stages.join(" → ") || "pipeline";
|
|
3053
|
+
const batch = {
|
|
3054
|
+
stage: stageName,
|
|
3055
|
+
samples: result,
|
|
3056
|
+
startIndex: 0,
|
|
3057
|
+
count: result.length,
|
|
3058
|
+
};
|
|
3059
|
+
this.callbacks.onBatch(batch);
|
|
3060
|
+
}
|
|
3061
|
+
// Execute onSample callbacks if provided (LEGACY - expensive)
|
|
3062
|
+
// WARNING: This can be expensive for large buffers
|
|
3063
|
+
if (this.callbacks?.onSample) {
|
|
3064
|
+
const stageName = this.stages.join(" → ") || "pipeline";
|
|
3065
|
+
for (let i = 0; i < result.length; i++) {
|
|
3066
|
+
this.callbacks.onSample(result[i], i, stageName);
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
// Execute onStageComplete callback
|
|
3070
|
+
if (this.callbacks?.onStageComplete) {
|
|
3071
|
+
const duration = performance.now() - startTime;
|
|
3072
|
+
const pipelineName = this.stages.join(" → ") || "pipeline";
|
|
3073
|
+
this.callbacks.onStageComplete(pipelineName, duration);
|
|
3074
|
+
}
|
|
3075
|
+
// Pool the completion log
|
|
3076
|
+
const duration = performance.now() - startTime;
|
|
3077
|
+
this.poolLog("info", "Pipeline processing completed", {
|
|
3078
|
+
durationMs: duration,
|
|
3079
|
+
sampleCount: result.length,
|
|
3080
|
+
});
|
|
3081
|
+
// Flush all pooled logs at the end
|
|
3082
|
+
this.flushLogs();
|
|
3083
|
+
return result;
|
|
3084
|
+
}
|
|
3085
|
+
catch (error) {
|
|
3086
|
+
const err = error;
|
|
3087
|
+
// Execute onError callback
|
|
3088
|
+
if (this.callbacks?.onError) {
|
|
3089
|
+
const pipelineName = this.stages.join(" → ") || "pipeline";
|
|
3090
|
+
this.callbacks.onError(pipelineName, err);
|
|
3091
|
+
}
|
|
3092
|
+
// Pool the error log
|
|
3093
|
+
this.poolLog("error", "Pipeline processing failed", {
|
|
3094
|
+
error: err.message,
|
|
3095
|
+
stack: err.stack,
|
|
3096
|
+
});
|
|
3097
|
+
// Flush logs even on error
|
|
3098
|
+
this.flushLogs();
|
|
3099
|
+
throw error;
|
|
3100
|
+
}
|
|
3101
|
+
}
|
|
3102
|
+
/**
|
|
3103
|
+
* Process a copy of the input data through the DSP pipeline
|
|
3104
|
+
* This method creates a copy of the input, so the original is preserved
|
|
3105
|
+
* * ⚠️ PERFORMANCE WARNING:
|
|
3106
|
+
* Only use this method inside Worker Threads or Cluster Workers.
|
|
3107
|
+
* Using this on the Main Thread of a server will block the Event Loop.
|
|
3108
|
+
* * This method bypasses the libuv thread pool, running DSP logic directly
|
|
3109
|
+
* on the calling thread. Ideal for high-frequency real-time audio loops.
|
|
3110
|
+
* @param input - Float32Array containing interleaved input samples (original is preserved)
|
|
3111
|
+
* @param timestampsOrOptions - Either timestamps array or processing options (sample rate and channel count)
|
|
3112
|
+
* @param optionsIfTimestamps - Processing options if timestamps were provided in second parameter
|
|
3113
|
+
* @returns Promise that resolves to a new Float32Array with the processed data
|
|
3114
|
+
*
|
|
3115
|
+
* @example
|
|
3116
|
+
* // Legacy sample-based (original preserved)
|
|
3117
|
+
* const output = await pipeline.processSyncCopy(samples, { sampleRate: 100, channels: 1 });
|
|
3118
|
+
*
|
|
3119
|
+
* @example
|
|
3120
|
+
* // Time-based with explicit timestamps (original preserved)
|
|
3121
|
+
* const output = await pipeline.processSyncCopy(samples, timestamps, { channels: 1 });
|
|
3122
|
+
*/
|
|
3123
|
+
processSyncCopy(input, timestampsOrOptions, optionsIfTimestamps) {
|
|
3124
|
+
const copy = Array.isArray(input)
|
|
3125
|
+
? DspUtils.interleave(input)
|
|
3126
|
+
: new Float32Array(input);
|
|
3127
|
+
// Handle both overloaded signatures by delegating to process()
|
|
3128
|
+
if (timestampsOrOptions instanceof Float32Array) {
|
|
3129
|
+
// Time-based mode: process(samples, timestamps, options)
|
|
3130
|
+
const timestampsCopy = new Float32Array(timestampsOrOptions);
|
|
3131
|
+
return this.processSync(copy, timestampsCopy, optionsIfTimestamps);
|
|
3132
|
+
}
|
|
3133
|
+
else {
|
|
3134
|
+
// Legacy mode: process(samples, options)
|
|
3135
|
+
return this.processSync(copy, timestampsOrOptions);
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* @brief Dispose of the DSP pipeline and free native resources
|
|
3140
|
+
* After calling this method, the instance should not be used again
|
|
3141
|
+
*
|
|
3142
|
+
* @example
|
|
3143
|
+
* const pipeline = createDspPipeline()
|
|
3144
|
+
* .MovingAverage({ mode: "moving", windowSize: 100 })
|
|
3145
|
+
* .Rectify({ mode: 'full' })
|
|
3146
|
+
* const output = await pipeline.process(samples, { sampleRate: 1000, channels: 1 });
|
|
3147
|
+
* pipeline.dispose();
|
|
3148
|
+
* // Free resources when done, pipeline cannot be used after this for processing the input signal since the stages have been disposed
|
|
3149
|
+
*/
|
|
3150
|
+
dispose() {
|
|
3151
|
+
this.nativeInstance.dispose();
|
|
3152
|
+
}
|
|
3153
|
+
/**
|
|
3154
|
+
* Save the current pipeline state
|
|
3155
|
+
* Supports two formats:
|
|
3156
|
+
* - JSON (default): Returns a string for text-based storage
|
|
3157
|
+
* - TOON: Returns a Buffer for binary serialization (60-70% smaller, faster)
|
|
2709
3158
|
*
|
|
2710
|
-
* @
|
|
3159
|
+
* @param options - Optional configuration with format: 'json' | 'toon'
|
|
3160
|
+
* @returns JSON string (default) or Buffer (if format: 'toon')
|
|
2711
3161
|
*
|
|
2712
3162
|
* @example
|
|
3163
|
+
* // Default JSON format
|
|
2713
3164
|
* const stateJson = await pipeline.saveState();
|
|
2714
3165
|
* await redis.set('dsp:state', stateJson);
|
|
3166
|
+
*
|
|
3167
|
+
* @example
|
|
3168
|
+
* // TOON binary format (smaller, faster)
|
|
3169
|
+
* const stateBinary = await pipeline.saveState({ format: 'toon' });
|
|
3170
|
+
* await redis.set('dsp:state', stateBinary);
|
|
2715
3171
|
*/
|
|
2716
|
-
async saveState() {
|
|
2717
|
-
return this.nativeInstance.saveState();
|
|
3172
|
+
async saveState(options) {
|
|
3173
|
+
return this.nativeInstance.saveState(options);
|
|
2718
3174
|
}
|
|
2719
3175
|
/**
|
|
2720
|
-
* Load pipeline state from
|
|
2721
|
-
*
|
|
3176
|
+
* Load pipeline state from JSON string or TOON binary Buffer
|
|
3177
|
+
* Auto-detects format: Buffer → TOON, string → JSON
|
|
2722
3178
|
*
|
|
2723
|
-
* @param
|
|
3179
|
+
* @param state - JSON string or TOON Buffer containing the pipeline state
|
|
2724
3180
|
* @returns Promise that resolves to true if successful
|
|
2725
3181
|
*
|
|
2726
3182
|
* @example
|
|
3183
|
+
* // Load JSON state
|
|
2727
3184
|
* const stateJson = await redis.get('dsp:state');
|
|
2728
3185
|
* if (stateJson) {
|
|
2729
3186
|
* await pipeline.loadState(stateJson);
|
|
2730
3187
|
* }
|
|
3188
|
+
*
|
|
3189
|
+
* @example
|
|
3190
|
+
* // Load TOON binary state (auto-detected)
|
|
3191
|
+
* const stateBinary = await redis.getBuffer('dsp:state');
|
|
3192
|
+
* if (stateBinary) {
|
|
3193
|
+
* await pipeline.loadState(stateBinary);
|
|
3194
|
+
* }
|
|
2731
3195
|
*/
|
|
2732
|
-
async loadState(
|
|
2733
|
-
return this.nativeInstance.loadState(
|
|
3196
|
+
async loadState(state) {
|
|
3197
|
+
return this.nativeInstance.loadState(state);
|
|
2734
3198
|
}
|
|
2735
3199
|
/**
|
|
2736
3200
|
* Clear all pipeline state (reset all filters to initial state)
|
|
@@ -2778,18 +3242,14 @@ class DspProcessor {
|
|
|
2778
3242
|
*
|
|
2779
3243
|
* @example
|
|
2780
3244
|
* // Create pipeline with Redis state persistence
|
|
2781
|
-
* const pipeline = createDspPipeline(
|
|
2782
|
-
* redisHost: 'localhost',
|
|
2783
|
-
* redisPort: 6379,
|
|
2784
|
-
* stateKey: 'dsp:channel1'
|
|
2785
|
-
* });
|
|
3245
|
+
* const pipeline = createDspPipeline();
|
|
2786
3246
|
*
|
|
2787
3247
|
* @example
|
|
2788
3248
|
* // Create pipeline without Redis (state is not persisted)
|
|
2789
3249
|
* const pipeline = createDspPipeline();
|
|
2790
3250
|
*/
|
|
2791
|
-
export function createDspPipeline(
|
|
2792
|
-
const nativeInstance = new DspAddon.DspPipeline(
|
|
3251
|
+
export function createDspPipeline() {
|
|
3252
|
+
const nativeInstance = new DspAddon.DspPipeline();
|
|
2793
3253
|
return new DspProcessor(nativeInstance);
|
|
2794
3254
|
}
|
|
2795
3255
|
export { DspProcessor };
|
|
@@ -3215,4 +3675,5 @@ export function calculateCommonSpatialPatterns(dataClass1, dataClass2, numChanne
|
|
|
3215
3675
|
}
|
|
3216
3676
|
return DspAddon.calculateCommonSpatialPatterns(dataClass1, dataClass2, numChannels, numFilters);
|
|
3217
3677
|
}
|
|
3678
|
+
export { FilterBankDesign } from "./FilterBankDesign.js";
|
|
3218
3679
|
//# sourceMappingURL=bindings.js.map
|