dspx 0.1.1-alpha.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/.github/workflows/ci.yml +185 -0
- package/.vscode/c_cpp_properties.json +17 -0
- package/.vscode/settings.json +68 -0
- package/.vscode/tasks.json +28 -0
- package/DISCLAIMER.md +32 -0
- package/LICENSE +21 -0
- package/README.md +1803 -0
- package/ROADMAP.md +192 -0
- package/TECHNICAL_DEBT.md +165 -0
- package/binding.gyp +65 -0
- package/docs/ADVANCED_LOGGER_FEATURES.md +598 -0
- package/docs/AUTHENTICATION_SECURITY.md +396 -0
- package/docs/BACKEND_IMPROVEMENTS.md +399 -0
- package/docs/CHEBYSHEV_BIQUAD_EQ_IMPLEMENTATION.md +405 -0
- package/docs/FFT_IMPLEMENTATION.md +490 -0
- package/docs/FFT_IMPROVEMENTS_SUMMARY.md +387 -0
- package/docs/FFT_USER_GUIDE.md +494 -0
- package/docs/FILTERS_IMPLEMENTATION.md +260 -0
- package/docs/FILTER_API_GUIDE.md +418 -0
- package/docs/FIR_SIMD_OPTIMIZATION.md +175 -0
- package/docs/LOGGER_API_REFERENCE.md +350 -0
- package/docs/NOTCH_FILTER_QUICK_REF.md +121 -0
- package/docs/PHASE2_TESTS_AND_NOTCH_FILTER.md +341 -0
- package/docs/PHASES_5_7_SUMMARY.md +403 -0
- package/docs/PIPELINE_FILTER_INTEGRATION.md +446 -0
- package/docs/SIMD_OPTIMIZATIONS.md +211 -0
- package/docs/TEST_MIGRATION_SUMMARY.md +173 -0
- package/docs/TIMESERIES_IMPLEMENTATION_SUMMARY.md +322 -0
- package/docs/TIMESERIES_QUICK_REF.md +85 -0
- package/docs/advanced.md +559 -0
- package/docs/time-series-guide.md +617 -0
- package/docs/time-series-migration.md +376 -0
- package/jest.config.js +37 -0
- package/package.json +42 -0
- package/prebuilds/linux-x64/dsp-ts-redis.node +0 -0
- package/prebuilds/win32-x64/dsp-ts-redis.node +0 -0
- package/scripts/test.js +24 -0
- package/src/build/dsp-ts-redis.node +0 -0
- package/src/native/DspPipeline.cc +675 -0
- package/src/native/DspPipeline.h +44 -0
- package/src/native/FftBindings.cc +817 -0
- package/src/native/FilterBindings.cc +1001 -0
- package/src/native/IDspStage.h +53 -0
- package/src/native/adapters/InterpolatorStage.h +201 -0
- package/src/native/adapters/MeanAbsoluteValueStage.h +289 -0
- package/src/native/adapters/MovingAverageStage.h +306 -0
- package/src/native/adapters/RectifyStage.h +88 -0
- package/src/native/adapters/ResamplerStage.h +238 -0
- package/src/native/adapters/RmsStage.h +299 -0
- package/src/native/adapters/SscStage.h +121 -0
- package/src/native/adapters/VarianceStage.h +307 -0
- package/src/native/adapters/WampStage.h +114 -0
- package/src/native/adapters/WaveformLengthStage.h +115 -0
- package/src/native/adapters/ZScoreNormalizeStage.h +326 -0
- package/src/native/core/FftEngine.cc +441 -0
- package/src/native/core/FftEngine.h +224 -0
- package/src/native/core/FirFilter.cc +324 -0
- package/src/native/core/FirFilter.h +149 -0
- package/src/native/core/IirFilter.cc +576 -0
- package/src/native/core/IirFilter.h +210 -0
- package/src/native/core/MovingAbsoluteValueFilter.cc +17 -0
- package/src/native/core/MovingAbsoluteValueFilter.h +135 -0
- package/src/native/core/MovingAverageFilter.cc +18 -0
- package/src/native/core/MovingAverageFilter.h +135 -0
- package/src/native/core/MovingFftFilter.cc +291 -0
- package/src/native/core/MovingFftFilter.h +203 -0
- package/src/native/core/MovingVarianceFilter.cc +194 -0
- package/src/native/core/MovingVarianceFilter.h +114 -0
- package/src/native/core/MovingZScoreFilter.cc +215 -0
- package/src/native/core/MovingZScoreFilter.h +113 -0
- package/src/native/core/Policies.h +352 -0
- package/src/native/core/RmsFilter.cc +18 -0
- package/src/native/core/RmsFilter.h +131 -0
- package/src/native/core/SscFilter.cc +16 -0
- package/src/native/core/SscFilter.h +137 -0
- package/src/native/core/WampFilter.cc +16 -0
- package/src/native/core/WampFilter.h +101 -0
- package/src/native/core/WaveformLengthFilter.cc +17 -0
- package/src/native/core/WaveformLengthFilter.h +98 -0
- package/src/native/utils/CircularBufferArray.cc +336 -0
- package/src/native/utils/CircularBufferArray.h +62 -0
- package/src/native/utils/CircularBufferVector.cc +145 -0
- package/src/native/utils/CircularBufferVector.h +45 -0
- package/src/native/utils/NapiUtils.cc +53 -0
- package/src/native/utils/NapiUtils.h +21 -0
- package/src/native/utils/SimdOps.h +870 -0
- package/src/native/utils/SlidingWindowFilter.cc +239 -0
- package/src/native/utils/SlidingWindowFilter.h +159 -0
- package/src/native/utils/TimeSeriesBuffer.cc +205 -0
- package/src/native/utils/TimeSeriesBuffer.h +140 -0
- package/src/ts/CircularLogBuffer.ts +87 -0
- package/src/ts/DriftDetector.ts +331 -0
- package/src/ts/TopicRouter.ts +428 -0
- package/src/ts/__tests__/AdvancedDsp.test.ts +585 -0
- package/src/ts/__tests__/AuthAndEdgeCases.test.ts +241 -0
- package/src/ts/__tests__/Chaining.test.ts +387 -0
- package/src/ts/__tests__/ChebyshevBiquad.test.ts +229 -0
- package/src/ts/__tests__/CircularLogBuffer.test.ts +158 -0
- package/src/ts/__tests__/DriftDetector.test.ts +389 -0
- package/src/ts/__tests__/Fft.test.ts +484 -0
- package/src/ts/__tests__/ListState.test.ts +153 -0
- package/src/ts/__tests__/Logger.test.ts +208 -0
- package/src/ts/__tests__/LoggerAdvanced.test.ts +319 -0
- package/src/ts/__tests__/LoggerMinor.test.ts +247 -0
- package/src/ts/__tests__/MeanAbsoluteValue.test.ts +398 -0
- package/src/ts/__tests__/MovingAverage.test.ts +322 -0
- package/src/ts/__tests__/RMS.test.ts +315 -0
- package/src/ts/__tests__/Rectify.test.ts +272 -0
- package/src/ts/__tests__/Redis.test.ts +456 -0
- package/src/ts/__tests__/SlopeSignChange.test.ts +166 -0
- package/src/ts/__tests__/Tap.test.ts +164 -0
- package/src/ts/__tests__/TimeBasedExpiration.test.ts +124 -0
- package/src/ts/__tests__/TimeBasedRmsAndMav.test.ts +231 -0
- package/src/ts/__tests__/TimeBasedVarianceAndZScore.test.ts +284 -0
- package/src/ts/__tests__/TimeSeries.test.ts +254 -0
- package/src/ts/__tests__/TopicRouter.test.ts +332 -0
- package/src/ts/__tests__/TopicRouterAdvanced.test.ts +483 -0
- package/src/ts/__tests__/TopicRouterPriority.test.ts +487 -0
- package/src/ts/__tests__/Variance.test.ts +509 -0
- package/src/ts/__tests__/WaveformLength.test.ts +147 -0
- package/src/ts/__tests__/WillisonAmplitude.test.ts +197 -0
- package/src/ts/__tests__/ZScoreNormalize.test.ts +459 -0
- package/src/ts/advanced-dsp.ts +566 -0
- package/src/ts/backends.ts +1137 -0
- package/src/ts/bindings.ts +1225 -0
- package/src/ts/easter-egg.ts +42 -0
- package/src/ts/examples/MeanAbsoluteValue/test-state.ts +99 -0
- package/src/ts/examples/MeanAbsoluteValue/test-streaming.ts +269 -0
- package/src/ts/examples/MovingAverage/test-state.ts +85 -0
- package/src/ts/examples/MovingAverage/test-streaming.ts +188 -0
- package/src/ts/examples/RMS/test-state.ts +97 -0
- package/src/ts/examples/RMS/test-streaming.ts +253 -0
- package/src/ts/examples/Rectify/test-state.ts +107 -0
- package/src/ts/examples/Rectify/test-streaming.ts +242 -0
- package/src/ts/examples/Variance/test-state.ts +195 -0
- package/src/ts/examples/Variance/test-streaming.ts +260 -0
- package/src/ts/examples/ZScoreNormalize/test-state.ts +277 -0
- package/src/ts/examples/ZScoreNormalize/test-streaming.ts +306 -0
- package/src/ts/examples/advanced-dsp-examples.ts +397 -0
- package/src/ts/examples/callbacks/advanced-router-features.ts +326 -0
- package/src/ts/examples/callbacks/benchmark-circular-buffer.ts +109 -0
- package/src/ts/examples/callbacks/monitoring-example.ts +265 -0
- package/src/ts/examples/callbacks/pipeline-callbacks-example.ts +137 -0
- package/src/ts/examples/callbacks/pooled-callbacks-example.ts +274 -0
- package/src/ts/examples/callbacks/priority-routing-example.ts +277 -0
- package/src/ts/examples/callbacks/production-topic-router.ts +214 -0
- package/src/ts/examples/callbacks/topic-based-logging.ts +161 -0
- package/src/ts/examples/chaining/test-chaining-redis.ts +113 -0
- package/src/ts/examples/chaining/test-chaining.ts +52 -0
- package/src/ts/examples/emg-features-example.ts +284 -0
- package/src/ts/examples/fft-example.ts +309 -0
- package/src/ts/examples/fft-examples.ts +349 -0
- package/src/ts/examples/filter-examples.ts +320 -0
- package/src/ts/examples/list-state-example.ts +131 -0
- package/src/ts/examples/logger-example.ts +91 -0
- package/src/ts/examples/notch-filter-examples.ts +243 -0
- package/src/ts/examples/phase5/drift-detection-example.ts +290 -0
- package/src/ts/examples/phase6-7/production-observability.ts +476 -0
- package/src/ts/examples/phase6-7/redis-timeseries-integration.ts +446 -0
- package/src/ts/examples/redis/redis-example.ts +202 -0
- package/src/ts/examples/redis-example.ts +202 -0
- package/src/ts/examples/simd-benchmark.ts +126 -0
- package/src/ts/examples/tap-debugging.ts +230 -0
- package/src/ts/examples/timeseries/comparison-example.ts +290 -0
- package/src/ts/examples/timeseries/iot-sensor-example.ts +143 -0
- package/src/ts/examples/timeseries/redis-streaming-example.ts +233 -0
- package/src/ts/examples/waveform-length-example.ts +139 -0
- package/src/ts/fft.ts +722 -0
- package/src/ts/filters.ts +1078 -0
- package/src/ts/index.ts +120 -0
- package/src/ts/types.ts +589 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import {
|
|
2
|
+
describe,
|
|
3
|
+
test,
|
|
4
|
+
before,
|
|
5
|
+
after,
|
|
6
|
+
beforeEach,
|
|
7
|
+
afterEach,
|
|
8
|
+
} from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { createDspPipeline, DspProcessor } from "../bindings.js";
|
|
11
|
+
import { createClient } from "redis";
|
|
12
|
+
import type { RedisClientType } from "redis";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_OPTIONS = { channels: 1, sampleRate: 44100 };
|
|
15
|
+
|
|
16
|
+
function assertCloseTo(actual: number, expected: number, precision = 5) {
|
|
17
|
+
const tolerance = Math.pow(10, -precision);
|
|
18
|
+
assert.ok(
|
|
19
|
+
Math.abs(actual - expected) < tolerance,
|
|
20
|
+
`Expected ${actual} to be close to ${expected} (tolerance: ${tolerance})`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Helper to check if Redis is available
|
|
25
|
+
async function isRedisAvailable(): Promise<boolean> {
|
|
26
|
+
try {
|
|
27
|
+
const client = createClient({
|
|
28
|
+
url: "redis://localhost:6379",
|
|
29
|
+
socket: { connectTimeout: 1000 },
|
|
30
|
+
});
|
|
31
|
+
await client.connect();
|
|
32
|
+
await client.disconnect();
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("Redis State Persistence", () => {
|
|
40
|
+
let redis: RedisClientType;
|
|
41
|
+
let redisAvailable: boolean;
|
|
42
|
+
|
|
43
|
+
before(async () => {
|
|
44
|
+
redisAvailable = await isRedisAvailable();
|
|
45
|
+
if (!redisAvailable) {
|
|
46
|
+
console.log(
|
|
47
|
+
"\n⚠️ Redis not available - skipping Redis integration tests"
|
|
48
|
+
);
|
|
49
|
+
console.log(
|
|
50
|
+
" To run these tests, start Redis: docker run -p 6379:6379 redis\n"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
beforeEach(async () => {
|
|
56
|
+
if (!redisAvailable) return;
|
|
57
|
+
|
|
58
|
+
redis = createClient({ url: "redis://localhost:6379" });
|
|
59
|
+
await redis.connect();
|
|
60
|
+
|
|
61
|
+
// Clean up test keys
|
|
62
|
+
const keys = await redis.keys("test:dsp:*");
|
|
63
|
+
if (keys.length > 0) {
|
|
64
|
+
await redis.del(keys);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
if (redis && redis.isOpen) {
|
|
70
|
+
await redis.disconnect();
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("Basic State Persistence", () => {
|
|
75
|
+
test("should save and restore state from Redis", async () => {
|
|
76
|
+
if (!redisAvailable) return;
|
|
77
|
+
|
|
78
|
+
const stateKey = "test:dsp:state:1";
|
|
79
|
+
const processor = createDspPipeline();
|
|
80
|
+
processor.MovingAverage({ mode: "moving", windowSize: 3 });
|
|
81
|
+
|
|
82
|
+
// Build state
|
|
83
|
+
await processor.process(
|
|
84
|
+
new Float32Array([1, 2, 3, 4, 5]),
|
|
85
|
+
DEFAULT_OPTIONS
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// Save to Redis
|
|
89
|
+
const stateJson = await processor.saveState();
|
|
90
|
+
await redis.set(stateKey, stateJson);
|
|
91
|
+
|
|
92
|
+
// Verify state was saved
|
|
93
|
+
const savedState = await redis.get(stateKey);
|
|
94
|
+
assert.ok(savedState);
|
|
95
|
+
assert.equal(savedState, stateJson);
|
|
96
|
+
|
|
97
|
+
// Create new processor and restore
|
|
98
|
+
const processor2 = createDspPipeline();
|
|
99
|
+
processor2.MovingAverage({ mode: "moving", windowSize: 3 });
|
|
100
|
+
|
|
101
|
+
const restoredState = await redis.get(stateKey);
|
|
102
|
+
assert.ok(restoredState);
|
|
103
|
+
await processor2.loadState(restoredState);
|
|
104
|
+
|
|
105
|
+
// Verify continuity
|
|
106
|
+
const output1 = await processor.process(
|
|
107
|
+
new Float32Array([6]),
|
|
108
|
+
DEFAULT_OPTIONS
|
|
109
|
+
);
|
|
110
|
+
const output2 = await processor2.process(
|
|
111
|
+
new Float32Array([6]),
|
|
112
|
+
DEFAULT_OPTIONS
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
assertCloseTo(output1[0], output2[0]);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("should handle missing state key gracefully", async () => {
|
|
119
|
+
if (!redisAvailable) return;
|
|
120
|
+
|
|
121
|
+
const stateKey = "test:dsp:state:nonexistent";
|
|
122
|
+
const state = await redis.get(stateKey);
|
|
123
|
+
|
|
124
|
+
assert.equal(state, null);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("should update state in Redis after processing", async () => {
|
|
128
|
+
if (!redisAvailable) return;
|
|
129
|
+
|
|
130
|
+
const stateKey = "test:dsp:state:2";
|
|
131
|
+
const processor = createDspPipeline();
|
|
132
|
+
processor.MovingAverage({ mode: "moving", windowSize: 2 });
|
|
133
|
+
|
|
134
|
+
// Initial state
|
|
135
|
+
await processor.process(new Float32Array([1, 2]), DEFAULT_OPTIONS);
|
|
136
|
+
const state1 = await processor.saveState();
|
|
137
|
+
await redis.set(stateKey, state1);
|
|
138
|
+
|
|
139
|
+
// Process more data
|
|
140
|
+
await processor.process(new Float32Array([3, 4]), DEFAULT_OPTIONS);
|
|
141
|
+
const state2 = await processor.saveState();
|
|
142
|
+
await redis.set(stateKey, state2);
|
|
143
|
+
|
|
144
|
+
// States should be different
|
|
145
|
+
assert.notEqual(state1, state2);
|
|
146
|
+
|
|
147
|
+
const savedState = await redis.get(stateKey);
|
|
148
|
+
assert.equal(savedState, state2);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("Multi-Stage Pipeline Persistence", () => {
|
|
153
|
+
test("should persist and restore complex pipeline", async () => {
|
|
154
|
+
if (!redisAvailable) return;
|
|
155
|
+
|
|
156
|
+
const stateKey = "test:dsp:state:complex";
|
|
157
|
+
const processor = createDspPipeline();
|
|
158
|
+
processor
|
|
159
|
+
.MovingAverage({ mode: "moving", windowSize: 3 })
|
|
160
|
+
.Rms({ mode: "moving", windowSize: 2 })
|
|
161
|
+
.Rectify({ mode: "full" });
|
|
162
|
+
|
|
163
|
+
// Build state
|
|
164
|
+
await processor.process(
|
|
165
|
+
new Float32Array([1, -2, 3, -4, 5]),
|
|
166
|
+
DEFAULT_OPTIONS
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Save to Redis
|
|
170
|
+
const stateJson = await processor.saveState();
|
|
171
|
+
await redis.set(stateKey, stateJson);
|
|
172
|
+
|
|
173
|
+
const state = JSON.parse(stateJson);
|
|
174
|
+
assert.equal(state.stages.length, 3);
|
|
175
|
+
|
|
176
|
+
// Restore in new processor
|
|
177
|
+
const processor2 = createDspPipeline();
|
|
178
|
+
processor2
|
|
179
|
+
.MovingAverage({ mode: "moving", windowSize: 3 })
|
|
180
|
+
.Rms({ mode: "moving", windowSize: 2 })
|
|
181
|
+
.Rectify({ mode: "full" });
|
|
182
|
+
|
|
183
|
+
const restoredState = await redis.get(stateKey);
|
|
184
|
+
assert.ok(restoredState);
|
|
185
|
+
await processor2.loadState(restoredState);
|
|
186
|
+
|
|
187
|
+
// Verify continuity
|
|
188
|
+
const output1 = await processor.process(
|
|
189
|
+
new Float32Array([6]),
|
|
190
|
+
DEFAULT_OPTIONS
|
|
191
|
+
);
|
|
192
|
+
const output2 = await processor2.process(
|
|
193
|
+
new Float32Array([6]),
|
|
194
|
+
DEFAULT_OPTIONS
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
assertCloseTo(output1[0], output2[0]);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("Streaming Scenario", () => {
|
|
202
|
+
test("should maintain state across simulated stream chunks", async () => {
|
|
203
|
+
if (!redisAvailable) return;
|
|
204
|
+
|
|
205
|
+
const stateKey = "test:dsp:state:stream";
|
|
206
|
+
const processor = createDspPipeline();
|
|
207
|
+
processor.MovingAverage({ mode: "moving", windowSize: 5 });
|
|
208
|
+
|
|
209
|
+
const chunks = [
|
|
210
|
+
new Float32Array([1, 2, 3]),
|
|
211
|
+
new Float32Array([4, 5, 6]),
|
|
212
|
+
new Float32Array([7, 8, 9]),
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
const outputs: Float32Array[] = [];
|
|
216
|
+
|
|
217
|
+
for (const chunk of chunks) {
|
|
218
|
+
const output = await processor.process(
|
|
219
|
+
new Float32Array(chunk),
|
|
220
|
+
DEFAULT_OPTIONS
|
|
221
|
+
);
|
|
222
|
+
outputs.push(output);
|
|
223
|
+
|
|
224
|
+
// Save state after each chunk
|
|
225
|
+
const state = await processor.saveState();
|
|
226
|
+
await redis.set(stateKey, state);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Verify all chunks processed
|
|
230
|
+
assert.equal(outputs.length, 3);
|
|
231
|
+
|
|
232
|
+
// Simulate restart and continue processing
|
|
233
|
+
const processor2 = createDspPipeline();
|
|
234
|
+
processor2.MovingAverage({ mode: "moving", windowSize: 5 });
|
|
235
|
+
|
|
236
|
+
const savedState = await redis.get(stateKey);
|
|
237
|
+
assert.ok(savedState);
|
|
238
|
+
await processor2.loadState(savedState);
|
|
239
|
+
|
|
240
|
+
// Continue with new chunk
|
|
241
|
+
const output1 = await processor.process(
|
|
242
|
+
new Float32Array([10]),
|
|
243
|
+
DEFAULT_OPTIONS
|
|
244
|
+
);
|
|
245
|
+
const output2 = await processor2.process(
|
|
246
|
+
new Float32Array([10]),
|
|
247
|
+
DEFAULT_OPTIONS
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
assertCloseTo(output1[0], output2[0]);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test("should handle rapid save/restore cycles", async () => {
|
|
254
|
+
if (!redisAvailable) return;
|
|
255
|
+
|
|
256
|
+
const stateKey = "test:dsp:state:rapid";
|
|
257
|
+
const processor = createDspPipeline();
|
|
258
|
+
processor.Rms({ mode: "moving", windowSize: 3 });
|
|
259
|
+
|
|
260
|
+
// Rapidly process and save
|
|
261
|
+
for (let i = 0; i < 10; i++) {
|
|
262
|
+
await processor.process(new Float32Array([i]), DEFAULT_OPTIONS);
|
|
263
|
+
const state = await processor.saveState();
|
|
264
|
+
await redis.set(stateKey, state);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Verify final state
|
|
268
|
+
const finalState = await redis.get(stateKey);
|
|
269
|
+
assert.ok(finalState);
|
|
270
|
+
|
|
271
|
+
const processor2 = createDspPipeline();
|
|
272
|
+
processor2.Rms({ mode: "moving", windowSize: 3 });
|
|
273
|
+
await processor2.loadState(finalState);
|
|
274
|
+
|
|
275
|
+
// Both should be in sync
|
|
276
|
+
const output1 = await processor.process(
|
|
277
|
+
new Float32Array([100]),
|
|
278
|
+
DEFAULT_OPTIONS
|
|
279
|
+
);
|
|
280
|
+
const output2 = await processor2.process(
|
|
281
|
+
new Float32Array([100]),
|
|
282
|
+
DEFAULT_OPTIONS
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
assertCloseTo(output1[0], output2[0]);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("Multi-Channel Scenarios", () => {
|
|
290
|
+
test("should persist state for multiple channels", async () => {
|
|
291
|
+
if (!redisAvailable) return;
|
|
292
|
+
|
|
293
|
+
const channel1Key = "test:dsp:state:ch1";
|
|
294
|
+
const channel2Key = "test:dsp:state:ch2";
|
|
295
|
+
|
|
296
|
+
// Channel 1
|
|
297
|
+
const processor1 = createDspPipeline();
|
|
298
|
+
processor1.MovingAverage({ mode: "moving", windowSize: 2 });
|
|
299
|
+
await processor1.process(new Float32Array([1, 2]), DEFAULT_OPTIONS);
|
|
300
|
+
await redis.set(channel1Key, await processor1.saveState());
|
|
301
|
+
|
|
302
|
+
// Channel 2
|
|
303
|
+
const processor2 = createDspPipeline();
|
|
304
|
+
processor2.MovingAverage({ mode: "moving", windowSize: 3 });
|
|
305
|
+
await processor2.process(new Float32Array([3, 4, 5]), DEFAULT_OPTIONS);
|
|
306
|
+
await redis.set(channel2Key, await processor2.saveState());
|
|
307
|
+
|
|
308
|
+
// Verify both states exist and are different
|
|
309
|
+
const state1 = await redis.get(channel1Key);
|
|
310
|
+
const state2 = await redis.get(channel2Key);
|
|
311
|
+
|
|
312
|
+
assert.ok(state1);
|
|
313
|
+
assert.ok(state2);
|
|
314
|
+
assert.notEqual(state1, state2);
|
|
315
|
+
|
|
316
|
+
// Verify different window sizes
|
|
317
|
+
const parsed1 = JSON.parse(state1);
|
|
318
|
+
const parsed2 = JSON.parse(state2);
|
|
319
|
+
|
|
320
|
+
assert.equal(parsed1.stages[0].state.windowSize, 2);
|
|
321
|
+
assert.equal(parsed2.stages[0].state.windowSize, 3);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
describe("State Expiration", () => {
|
|
326
|
+
test("should set TTL on state keys for automatic cleanup", async () => {
|
|
327
|
+
if (!redisAvailable) return;
|
|
328
|
+
|
|
329
|
+
const stateKey = "test:dsp:state:ttl";
|
|
330
|
+
const processor = createDspPipeline();
|
|
331
|
+
processor.MovingAverage({ mode: "moving", windowSize: 2 });
|
|
332
|
+
|
|
333
|
+
await processor.process(new Float32Array([1, 2]), DEFAULT_OPTIONS);
|
|
334
|
+
const state = await processor.saveState();
|
|
335
|
+
|
|
336
|
+
// Save with 60 second TTL
|
|
337
|
+
await redis.setEx(stateKey, 60, state);
|
|
338
|
+
|
|
339
|
+
// Verify TTL is set
|
|
340
|
+
const ttl = await redis.ttl(stateKey);
|
|
341
|
+
assert.ok(ttl > 0 && ttl <= 60);
|
|
342
|
+
|
|
343
|
+
// Verify state can be retrieved
|
|
344
|
+
const retrieved = await redis.get(stateKey);
|
|
345
|
+
assert.equal(retrieved, state);
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe("Error Handling", () => {
|
|
350
|
+
test("should handle corrupted state in Redis", async () => {
|
|
351
|
+
if (!redisAvailable) return;
|
|
352
|
+
|
|
353
|
+
const stateKey = "test:dsp:state:corrupted";
|
|
354
|
+
const processor = createDspPipeline();
|
|
355
|
+
processor.MovingAverage({ mode: "moving", windowSize: 3 });
|
|
356
|
+
|
|
357
|
+
// Save corrupted JSON
|
|
358
|
+
await redis.set(stateKey, "{ invalid json }");
|
|
359
|
+
|
|
360
|
+
// Should throw when trying to load
|
|
361
|
+
await assert.rejects(async () => {
|
|
362
|
+
const state = await redis.get(stateKey);
|
|
363
|
+
if (state) {
|
|
364
|
+
JSON.parse(state); // This should throw
|
|
365
|
+
}
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("should handle state validation failure", async () => {
|
|
370
|
+
if (!redisAvailable) return;
|
|
371
|
+
|
|
372
|
+
const stateKey = "test:dsp:state:invalid";
|
|
373
|
+
const processor = createDspPipeline();
|
|
374
|
+
processor.MovingAverage({ mode: "moving", windowSize: 3 });
|
|
375
|
+
|
|
376
|
+
await processor.process(new Float32Array([1, 2, 3]), DEFAULT_OPTIONS);
|
|
377
|
+
const stateJson = await processor.saveState();
|
|
378
|
+
const state = JSON.parse(stateJson);
|
|
379
|
+
|
|
380
|
+
// Corrupt the state
|
|
381
|
+
if (state.stages[0].state.channels && state.stages[0].state.channels[0]) {
|
|
382
|
+
state.stages[0].state.channels[0].runningSum = 9999;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
await redis.set(stateKey, JSON.stringify(state));
|
|
386
|
+
|
|
387
|
+
// Should throw validation error
|
|
388
|
+
const processor2 = createDspPipeline();
|
|
389
|
+
processor2.MovingAverage({ mode: "moving", windowSize: 3 });
|
|
390
|
+
|
|
391
|
+
const corruptedState = await redis.get(stateKey);
|
|
392
|
+
assert.ok(corruptedState);
|
|
393
|
+
|
|
394
|
+
await assert.rejects(
|
|
395
|
+
async () => await processor2.loadState(corruptedState),
|
|
396
|
+
/Running sum validation failed/
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
describe("State Metadata", () => {
|
|
402
|
+
test("should include timestamp in saved state", async () => {
|
|
403
|
+
if (!redisAvailable) return;
|
|
404
|
+
|
|
405
|
+
const stateKey = "test:dsp:state:timestamp";
|
|
406
|
+
const processor = createDspPipeline();
|
|
407
|
+
processor.MovingAverage({ mode: "moving", windowSize: 2 });
|
|
408
|
+
|
|
409
|
+
await processor.process(new Float32Array([1, 2]), DEFAULT_OPTIONS);
|
|
410
|
+
const stateJson = await processor.saveState();
|
|
411
|
+
await redis.set(stateKey, stateJson);
|
|
412
|
+
|
|
413
|
+
const state = JSON.parse(stateJson);
|
|
414
|
+
assert.ok(state.timestamp);
|
|
415
|
+
assert.ok(new Date(state.timestamp).getTime() > 0);
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
test("should track state version across updates", async () => {
|
|
419
|
+
if (!redisAvailable) return;
|
|
420
|
+
|
|
421
|
+
const stateKey = "test:dsp:state:version";
|
|
422
|
+
const metadataKey = "test:dsp:state:version:meta";
|
|
423
|
+
const processor = createDspPipeline();
|
|
424
|
+
processor.MovingAverage({ mode: "moving", windowSize: 2 });
|
|
425
|
+
|
|
426
|
+
// Save multiple versions
|
|
427
|
+
const versions: string[] = [];
|
|
428
|
+
const states: string[] = [];
|
|
429
|
+
|
|
430
|
+
for (let i = 0; i < 3; i++) {
|
|
431
|
+
await processor.process(new Float32Array([i]), DEFAULT_OPTIONS);
|
|
432
|
+
const state = await processor.saveState();
|
|
433
|
+
await redis.set(stateKey, state);
|
|
434
|
+
versions.push(JSON.parse(state).timestamp);
|
|
435
|
+
states.push(state);
|
|
436
|
+
|
|
437
|
+
// Track version count
|
|
438
|
+
await redis.incr(metadataKey);
|
|
439
|
+
|
|
440
|
+
// Small delay to ensure different timestamps
|
|
441
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const versionCount = await redis.get(metadataKey);
|
|
445
|
+
assert.equal(versionCount, "3");
|
|
446
|
+
|
|
447
|
+
// States should be different (different buffer contents)
|
|
448
|
+
assert.notEqual(states[0], states[1]);
|
|
449
|
+
assert.notEqual(states[1], states[2]);
|
|
450
|
+
|
|
451
|
+
// At least some timestamps should be different
|
|
452
|
+
const uniqueTimestamps = new Set(versions);
|
|
453
|
+
assert.ok(uniqueTimestamps.size >= 1, "Should have valid timestamps");
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
});
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, test, beforeEach } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { createDspPipeline, DspProcessor } from "../bindings.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_OPTIONS = { channels: 1, sampleRate: 44100 };
|
|
6
|
+
|
|
7
|
+
describe("Slope Sign Change (SSC)", () => {
|
|
8
|
+
let pipeline: DspProcessor;
|
|
9
|
+
|
|
10
|
+
beforeEach(() => {
|
|
11
|
+
pipeline = createDspPipeline();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("should count slope sign changes with zero threshold", async () => {
|
|
15
|
+
pipeline.SlopeSignChange({ windowSize: 5, threshold: 0 });
|
|
16
|
+
|
|
17
|
+
// Signal: [1, 3, 2, 4, 3, 5]
|
|
18
|
+
// Slopes: +, -, +, -, +
|
|
19
|
+
// Sign changes: at indices 2, 3, 4, 5
|
|
20
|
+
// Windowed counting: returns count WITHIN last 5 samples
|
|
21
|
+
const buffer = new Float32Array([1, 3, 2, 4, 3, 5]);
|
|
22
|
+
await pipeline.process(buffer, DEFAULT_OPTIONS);
|
|
23
|
+
|
|
24
|
+
assert.strictEqual(buffer[0], 0); // Need 2 previous samples
|
|
25
|
+
assert.strictEqual(buffer[1], 0); // Need 1 more previous sample
|
|
26
|
+
assert.strictEqual(buffer[2], 0); // First sign change, but window filling
|
|
27
|
+
assert.strictEqual(buffer[3], 1); // Sign change detected in window
|
|
28
|
+
assert.strictEqual(buffer[4], 1); // Count within sliding window (window size 5)
|
|
29
|
+
assert.strictEqual(buffer[5], 1); // Count within sliding window
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("should apply threshold correctly", async () => {
|
|
33
|
+
pipeline.SlopeSignChange({ windowSize: 4, threshold: 1.0 });
|
|
34
|
+
|
|
35
|
+
// Signal: [0, 0.5, 1.0, 0.5, 1.5, 0.5]
|
|
36
|
+
// Differences: 0.5, 0.5, -0.5, 1.0, -1.0
|
|
37
|
+
// With threshold 1.0, the PRODUCT of consecutive differences must exceed threshold
|
|
38
|
+
// SSC checks: (diff1 * diff2) > threshold
|
|
39
|
+
// Windowed counting: returns count WITHIN last 4 samples
|
|
40
|
+
const buffer = new Float32Array([0, 0.5, 1.0, 0.5, 1.5, 0.5]);
|
|
41
|
+
await pipeline.process(buffer, DEFAULT_OPTIONS);
|
|
42
|
+
|
|
43
|
+
assert.strictEqual(buffer[0], 0);
|
|
44
|
+
assert.strictEqual(buffer[1], 0);
|
|
45
|
+
assert.strictEqual(buffer[2], 0); // Product: 0.5*0.5 = 0.25 < 1.0
|
|
46
|
+
assert.strictEqual(buffer[3], 0); // Product: 0.5*-0.5 = -0.25 < 1.0
|
|
47
|
+
assert.strictEqual(buffer[4], 0); // Product: -0.5*1.0 = -0.5 < 1.0
|
|
48
|
+
assert.strictEqual(buffer[5], 0); // Product: 1.0*-1.0 = -1.0 < 1.0
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("should handle multi-channel SSC", async () => {
|
|
52
|
+
pipeline.SlopeSignChange({ windowSize: 3, threshold: 0 });
|
|
53
|
+
|
|
54
|
+
// 2 channels
|
|
55
|
+
// Ch0: [1, 2, 1, 2] - slopes: +, -, +
|
|
56
|
+
// Ch1: [2, 1, 2, 1] - slopes: -, +, -
|
|
57
|
+
// Windowed counting: returns count WITHIN last 3 samples
|
|
58
|
+
const buffer = new Float32Array([
|
|
59
|
+
1,
|
|
60
|
+
2, // Sample 0
|
|
61
|
+
2,
|
|
62
|
+
1, // Sample 1
|
|
63
|
+
1,
|
|
64
|
+
2, // Sample 2
|
|
65
|
+
2,
|
|
66
|
+
1, // Sample 3
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
await pipeline.process(buffer, { channels: 2, sampleRate: 44100 });
|
|
70
|
+
|
|
71
|
+
// Channel 0
|
|
72
|
+
assert.strictEqual(buffer[0], 0);
|
|
73
|
+
assert.strictEqual(buffer[2], 0);
|
|
74
|
+
assert.strictEqual(buffer[4], 0); // First sign change, but window filling
|
|
75
|
+
assert.strictEqual(buffer[6], 1); // Count within sliding window (window size 3)
|
|
76
|
+
|
|
77
|
+
// Channel 1
|
|
78
|
+
assert.strictEqual(buffer[1], 0);
|
|
79
|
+
assert.strictEqual(buffer[3], 0);
|
|
80
|
+
assert.strictEqual(buffer[5], 0); // First sign change, but window filling
|
|
81
|
+
assert.strictEqual(buffer[7], 1); // Count within sliding window
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("should handle monotonic signal (no sign changes)", async () => {
|
|
85
|
+
pipeline.SlopeSignChange({ windowSize: 5, threshold: 0 });
|
|
86
|
+
|
|
87
|
+
const buffer = new Float32Array([1, 2, 3, 4, 5]);
|
|
88
|
+
await pipeline.process(buffer, DEFAULT_OPTIONS);
|
|
89
|
+
|
|
90
|
+
// All slopes are positive, no sign changes
|
|
91
|
+
assert.strictEqual(buffer[0], 0);
|
|
92
|
+
assert.strictEqual(buffer[1], 0);
|
|
93
|
+
assert.strictEqual(buffer[2], 0);
|
|
94
|
+
assert.strictEqual(buffer[3], 0);
|
|
95
|
+
assert.strictEqual(buffer[4], 0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("should handle constant signal", async () => {
|
|
99
|
+
pipeline.SlopeSignChange({ windowSize: 4, threshold: 0 });
|
|
100
|
+
|
|
101
|
+
const buffer = new Float32Array([5, 5, 5, 5]);
|
|
102
|
+
await pipeline.process(buffer, DEFAULT_OPTIONS);
|
|
103
|
+
|
|
104
|
+
// No slopes, no sign changes
|
|
105
|
+
assert.strictEqual(buffer[0], 0);
|
|
106
|
+
assert.strictEqual(buffer[1], 0);
|
|
107
|
+
assert.strictEqual(buffer[2], 0);
|
|
108
|
+
assert.strictEqual(buffer[3], 0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("should reset state correctly", async () => {
|
|
112
|
+
pipeline.SlopeSignChange({ windowSize: 3, threshold: 0 });
|
|
113
|
+
|
|
114
|
+
const buffer1 = new Float32Array([1, 3, 2, 4]);
|
|
115
|
+
await pipeline.process(buffer1, DEFAULT_OPTIONS);
|
|
116
|
+
|
|
117
|
+
pipeline.clearState();
|
|
118
|
+
|
|
119
|
+
const buffer2 = new Float32Array([1, 3, 2, 4]);
|
|
120
|
+
await pipeline.process(buffer2, DEFAULT_OPTIONS);
|
|
121
|
+
|
|
122
|
+
// After reset, should get same results
|
|
123
|
+
for (let i = 0; i < buffer1.length; i++) {
|
|
124
|
+
assert.strictEqual(buffer1[i], buffer2[i]);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("should serialize and deserialize state", async () => {
|
|
129
|
+
pipeline.SlopeSignChange({ windowSize: 4, threshold: 0 });
|
|
130
|
+
|
|
131
|
+
const buffer = new Float32Array([1, 3, 2, 4, 3]);
|
|
132
|
+
await pipeline.process(buffer, DEFAULT_OPTIONS);
|
|
133
|
+
|
|
134
|
+
const state = await pipeline.saveState();
|
|
135
|
+
|
|
136
|
+
const newPipeline = createDspPipeline();
|
|
137
|
+
newPipeline.SlopeSignChange({ windowSize: 4, threshold: 0 }); // Must match original pipeline
|
|
138
|
+
await newPipeline.loadState(state);
|
|
139
|
+
|
|
140
|
+
const buffer2 = new Float32Array([5, 4]);
|
|
141
|
+
await newPipeline.process(buffer2, DEFAULT_OPTIONS);
|
|
142
|
+
|
|
143
|
+
// Should continue counting from where we left off
|
|
144
|
+
assert.ok(buffer2[0] > 0);
|
|
145
|
+
assert.ok(buffer2[1] > 0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("should throw error for invalid window size", () => {
|
|
149
|
+
assert.throws(() => {
|
|
150
|
+
pipeline.SlopeSignChange({ windowSize: 0, threshold: 0 });
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("should throw error for missing window size", () => {
|
|
155
|
+
assert.throws(() => {
|
|
156
|
+
// @ts-expect-error - Testing missing windowSize
|
|
157
|
+
pipeline.SlopeSignChange({ threshold: 0 });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("should default to zero threshold when not specified", async () => {
|
|
162
|
+
assert.doesNotThrow(() => {
|
|
163
|
+
pipeline.SlopeSignChange({ windowSize: 5 });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
});
|