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,484 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for FFT/DFT Engine
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it } from "node:test";
|
|
6
|
+
import assert from "node:assert";
|
|
7
|
+
import {
|
|
8
|
+
FftProcessor,
|
|
9
|
+
MovingFftProcessor,
|
|
10
|
+
FftUtils,
|
|
11
|
+
type ComplexArray,
|
|
12
|
+
} from "../fft.js";
|
|
13
|
+
|
|
14
|
+
describe("FftProcessor - Basic Transforms", () => {
|
|
15
|
+
it("should create FFT processor with power-of-2 size", () => {
|
|
16
|
+
const fft = new FftProcessor(256);
|
|
17
|
+
assert.strictEqual(fft.getSize(), 256);
|
|
18
|
+
assert.strictEqual(fft.getHalfSize(), 129);
|
|
19
|
+
assert.strictEqual(fft.isPowerOfTwo(), true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should create DFT processor with non-power-of-2 size", () => {
|
|
23
|
+
const fft = new FftProcessor(100);
|
|
24
|
+
assert.strictEqual(fft.getSize(), 100);
|
|
25
|
+
assert.strictEqual(fft.isPowerOfTwo(), false);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should compute RFFT for real signal", () => {
|
|
29
|
+
const size = 256;
|
|
30
|
+
const fft = new FftProcessor(size);
|
|
31
|
+
|
|
32
|
+
// Generate sine wave at bin 10 (10 * sampleRate / 256)
|
|
33
|
+
const signal = new Float32Array(size);
|
|
34
|
+
const freq = 10;
|
|
35
|
+
for (let i = 0; i < size; i++) {
|
|
36
|
+
signal[i] = Math.sin((2 * Math.PI * freq * i) / size);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const spectrum = fft.rfft(signal);
|
|
40
|
+
|
|
41
|
+
assert.ok(spectrum.real instanceof Float32Array);
|
|
42
|
+
assert.ok(spectrum.imag instanceof Float32Array);
|
|
43
|
+
assert.strictEqual(spectrum.real.length, fft.getHalfSize());
|
|
44
|
+
assert.strictEqual(spectrum.imag.length, fft.getHalfSize());
|
|
45
|
+
|
|
46
|
+
// Check peak is at bin 10
|
|
47
|
+
const magnitudes = fft.getMagnitude(spectrum);
|
|
48
|
+
let peakBin = 0;
|
|
49
|
+
let peakValue = 0;
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < magnitudes.length; i++) {
|
|
52
|
+
if (magnitudes[i] > peakValue) {
|
|
53
|
+
peakValue = magnitudes[i];
|
|
54
|
+
peakBin = i;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
assert.strictEqual(peakBin, freq);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should reconstruct signal with IRFFT", () => {
|
|
62
|
+
const size = 128;
|
|
63
|
+
const fft = new FftProcessor(size);
|
|
64
|
+
|
|
65
|
+
// Original signal
|
|
66
|
+
const original = new Float32Array(size);
|
|
67
|
+
for (let i = 0; i < size; i++) {
|
|
68
|
+
original[i] = Math.sin((2 * Math.PI * 5 * i) / size);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Forward -> Inverse
|
|
72
|
+
const spectrum = fft.rfft(original);
|
|
73
|
+
const reconstructed = fft.irfft(spectrum);
|
|
74
|
+
|
|
75
|
+
// Check reconstruction accuracy
|
|
76
|
+
for (let i = 0; i < size; i++) {
|
|
77
|
+
assert.ok(Math.abs(reconstructed[i] - original[i]) < 1e-5);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("should compute DFT for non-power-of-2 sizes", () => {
|
|
82
|
+
const size = 100;
|
|
83
|
+
const fft = new FftProcessor(size);
|
|
84
|
+
|
|
85
|
+
const signal = new Float32Array(size);
|
|
86
|
+
for (let i = 0; i < size; i++) {
|
|
87
|
+
signal[i] = Math.cos((2 * Math.PI * 7 * i) / size);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const spectrum = fft.rdft(signal);
|
|
91
|
+
|
|
92
|
+
assert.strictEqual(spectrum.real.length, 51); // 100/2 + 1
|
|
93
|
+
assert.ok(spectrum.real instanceof Float32Array);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("FftProcessor - Complex Transforms", () => {
|
|
98
|
+
it("should compute FFT for complex signal", () => {
|
|
99
|
+
const size = 64;
|
|
100
|
+
const fft = new FftProcessor(size);
|
|
101
|
+
|
|
102
|
+
const input: ComplexArray = {
|
|
103
|
+
real: new Float32Array(size),
|
|
104
|
+
imag: new Float32Array(size),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// Create complex exponential: e^(j2πk/N)
|
|
108
|
+
const k = 5;
|
|
109
|
+
for (let n = 0; n < size; n++) {
|
|
110
|
+
input.real[n] = Math.cos((2 * Math.PI * k * n) / size);
|
|
111
|
+
input.imag[n] = Math.sin((2 * Math.PI * k * n) / size);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const spectrum = fft.fft(input);
|
|
115
|
+
|
|
116
|
+
assert.strictEqual(spectrum.real.length, size);
|
|
117
|
+
assert.strictEqual(spectrum.imag.length, size);
|
|
118
|
+
|
|
119
|
+
// Peak should be at bin k
|
|
120
|
+
const magnitudes = fft.getMagnitude(spectrum);
|
|
121
|
+
let peakBin = 0;
|
|
122
|
+
let peakValue = 0;
|
|
123
|
+
|
|
124
|
+
for (let i = 0; i < magnitudes.length; i++) {
|
|
125
|
+
if (magnitudes[i] > peakValue) {
|
|
126
|
+
peakValue = magnitudes[i];
|
|
127
|
+
peakBin = i;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
assert.strictEqual(peakBin, k);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("should reconstruct complex signal with IFFT", () => {
|
|
135
|
+
const size = 32;
|
|
136
|
+
const fft = new FftProcessor(size);
|
|
137
|
+
|
|
138
|
+
const original: ComplexArray = {
|
|
139
|
+
real: new Float32Array(size),
|
|
140
|
+
imag: new Float32Array(size),
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
for (let i = 0; i < size; i++) {
|
|
144
|
+
original.real[i] = Math.sin((2 * Math.PI * 3 * i) / size);
|
|
145
|
+
original.imag[i] = Math.cos((2 * Math.PI * 3 * i) / size);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const spectrum = fft.fft(original);
|
|
149
|
+
const reconstructed = fft.ifft(spectrum);
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < size; i++) {
|
|
152
|
+
assert.ok(Math.abs(reconstructed.real[i] - original.real[i]) < 1e-5);
|
|
153
|
+
assert.ok(Math.abs(reconstructed.imag[i] - original.imag[i]) < 1e-5);
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe("FftProcessor - Spectral Analysis", () => {
|
|
159
|
+
it("should compute magnitude spectrum", () => {
|
|
160
|
+
const size = 128;
|
|
161
|
+
const fft = new FftProcessor(size);
|
|
162
|
+
|
|
163
|
+
const signal = new Float32Array(size);
|
|
164
|
+
for (let i = 0; i < size; i++) {
|
|
165
|
+
signal[i] = Math.sin((2 * Math.PI * 8 * i) / size);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const spectrum = fft.rfft(signal);
|
|
169
|
+
const magnitudes = fft.getMagnitude(spectrum);
|
|
170
|
+
|
|
171
|
+
assert.strictEqual(magnitudes.length, fft.getHalfSize());
|
|
172
|
+
assert.ok(magnitudes.every((m) => m >= 0)); // Magnitudes always positive
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("should compute phase spectrum", () => {
|
|
176
|
+
const size = 64;
|
|
177
|
+
const fft = new FftProcessor(size);
|
|
178
|
+
|
|
179
|
+
const signal = new Float32Array(size);
|
|
180
|
+
for (let i = 0; i < size; i++) {
|
|
181
|
+
signal[i] = Math.cos((2 * Math.PI * 4 * i) / size);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const spectrum = fft.rfft(signal);
|
|
185
|
+
const phases = fft.getPhase(spectrum);
|
|
186
|
+
|
|
187
|
+
assert.strictEqual(phases.length, fft.getHalfSize());
|
|
188
|
+
// Phase should be near 0 for cosine (even function)
|
|
189
|
+
assert.ok(Math.abs(phases[4]) < 0.1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should compute power spectrum", () => {
|
|
193
|
+
const size = 256;
|
|
194
|
+
const fft = new FftProcessor(size);
|
|
195
|
+
|
|
196
|
+
const signal = new Float32Array(size);
|
|
197
|
+
for (let i = 0; i < size; i++) {
|
|
198
|
+
signal[i] = Math.sin((2 * Math.PI * 10 * i) / size);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const spectrum = fft.rfft(signal);
|
|
202
|
+
const power = fft.getPower(spectrum);
|
|
203
|
+
|
|
204
|
+
assert.strictEqual(power.length, fft.getHalfSize());
|
|
205
|
+
assert.ok(power.every((p) => p >= 0));
|
|
206
|
+
|
|
207
|
+
// Power = magnitude²
|
|
208
|
+
const magnitudes = fft.getMagnitude(spectrum);
|
|
209
|
+
for (let i = 0; i < power.length; i++) {
|
|
210
|
+
assert.ok(Math.abs(power[i] - magnitudes[i] ** 2) < 1e-4);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("should compute frequency bins correctly", () => {
|
|
215
|
+
const size = 1024;
|
|
216
|
+
const sampleRate = 44100;
|
|
217
|
+
const fft = new FftProcessor(size);
|
|
218
|
+
|
|
219
|
+
const freqs = fft.getFrequencyBins(sampleRate);
|
|
220
|
+
|
|
221
|
+
assert.strictEqual(freqs.length, fft.getHalfSize());
|
|
222
|
+
assert.strictEqual(freqs[0], 0); // DC
|
|
223
|
+
assert.ok(Math.abs(freqs[freqs.length - 1] - sampleRate / 2) < 1); // Nyquist
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("FftProcessor - Parseval's Theorem", () => {
|
|
228
|
+
it("should conserve energy (Parseval)", () => {
|
|
229
|
+
const size = 256;
|
|
230
|
+
const fft = new FftProcessor(size);
|
|
231
|
+
|
|
232
|
+
const signal = new Float32Array(size);
|
|
233
|
+
for (let i = 0; i < size; i++) {
|
|
234
|
+
signal[i] = Math.random() * 2 - 1; // Random signal
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Time-domain energy
|
|
238
|
+
let timeEnergy = 0;
|
|
239
|
+
for (let i = 0; i < size; i++) {
|
|
240
|
+
timeEnergy += signal[i] * signal[i];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Frequency-domain energy
|
|
244
|
+
const spectrum = fft.rfft(signal);
|
|
245
|
+
const power = fft.getPower(spectrum);
|
|
246
|
+
|
|
247
|
+
let freqEnergy = 0;
|
|
248
|
+
freqEnergy += power[0]; // DC
|
|
249
|
+
for (let i = 1; i < power.length - 1; i++) {
|
|
250
|
+
freqEnergy += 2 * power[i]; // Account for negative frequencies
|
|
251
|
+
}
|
|
252
|
+
freqEnergy += power[power.length - 1]; // Nyquist
|
|
253
|
+
freqEnergy /= size; // Normalize
|
|
254
|
+
|
|
255
|
+
// Check energy conservation
|
|
256
|
+
assert.ok(Math.abs(timeEnergy - freqEnergy) / timeEnergy < 0.01);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
describe("MovingFftProcessor - Batched Mode", () => {
|
|
261
|
+
it("should process batched FFT with hop size", () => {
|
|
262
|
+
const fftSize = 128;
|
|
263
|
+
const hopSize = 64;
|
|
264
|
+
|
|
265
|
+
const movingFft = new MovingFftProcessor({
|
|
266
|
+
fftSize,
|
|
267
|
+
hopSize,
|
|
268
|
+
mode: "batched",
|
|
269
|
+
windowType: "hann",
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const samples = new Float32Array(256);
|
|
273
|
+
for (let i = 0; i < samples.length; i++) {
|
|
274
|
+
samples[i] = Math.sin((2 * Math.PI * 10 * i) / fftSize);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let spectrumCount = 0;
|
|
278
|
+
movingFft.addSamples(samples, (spectrum, size) => {
|
|
279
|
+
assert.strictEqual(size, fftSize / 2 + 1);
|
|
280
|
+
spectrumCount++;
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Should produce 3 spectra: at samples 128, 192, 256
|
|
284
|
+
assert.strictEqual(spectrumCount, 3);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("should apply windowing correctly", () => {
|
|
288
|
+
const fftSize = 64;
|
|
289
|
+
|
|
290
|
+
const movingFft = new MovingFftProcessor({
|
|
291
|
+
fftSize,
|
|
292
|
+
mode: "batched",
|
|
293
|
+
windowType: "hann",
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// Fill buffer
|
|
297
|
+
const samples = new Float32Array(fftSize);
|
|
298
|
+
samples.fill(1.0); // Constant signal
|
|
299
|
+
|
|
300
|
+
movingFft.addSamples(samples);
|
|
301
|
+
const spectrum = movingFft.computeSpectrum();
|
|
302
|
+
|
|
303
|
+
// Windowing should reduce spectral leakage
|
|
304
|
+
assert.ok(spectrum.real[0] > 0); // DC component preserved
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("should reset state correctly", () => {
|
|
308
|
+
const movingFft = new MovingFftProcessor({
|
|
309
|
+
fftSize: 64,
|
|
310
|
+
hopSize: 32,
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const samples = new Float32Array(128);
|
|
314
|
+
movingFft.addSamples(samples);
|
|
315
|
+
movingFft.reset();
|
|
316
|
+
|
|
317
|
+
// After reset, should need full buffer again
|
|
318
|
+
const samples2 = new Float32Array(32);
|
|
319
|
+
let called = false;
|
|
320
|
+
movingFft.addSamples(samples2, () => {
|
|
321
|
+
called = true;
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
assert.strictEqual(called, false); // Not enough samples yet
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("FftUtils - Helper Functions", () => {
|
|
329
|
+
it("should find peak frequency", () => {
|
|
330
|
+
const fftSize = 1024;
|
|
331
|
+
const sampleRate = 44100;
|
|
332
|
+
const targetFreq = 440; // A4
|
|
333
|
+
|
|
334
|
+
const fft = new FftProcessor(fftSize);
|
|
335
|
+
|
|
336
|
+
const signal = new Float32Array(fftSize);
|
|
337
|
+
for (let i = 0; i < fftSize; i++) {
|
|
338
|
+
signal[i] = Math.sin((2 * Math.PI * targetFreq * i) / sampleRate);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const spectrum = fft.rfft(signal);
|
|
342
|
+
const magnitudes = fft.getMagnitude(spectrum);
|
|
343
|
+
|
|
344
|
+
const peakFreq = FftUtils.findPeakFrequency(
|
|
345
|
+
magnitudes,
|
|
346
|
+
sampleRate,
|
|
347
|
+
fftSize
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
assert.ok(Math.abs(peakFreq - targetFreq) < sampleRate / fftSize);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("should convert to decibels", () => {
|
|
354
|
+
const magnitudes = new Float32Array([1.0, 0.5, 0.1, 0.01]);
|
|
355
|
+
const db = FftUtils.toDecibels(magnitudes);
|
|
356
|
+
|
|
357
|
+
assert.ok(Math.abs(db[0] - 0) < 0.1); // 1.0 = 0 dB
|
|
358
|
+
assert.ok(Math.abs(db[1] - -6.02) < 0.1); // 0.5 ≈ -6 dB
|
|
359
|
+
assert.ok(Math.abs(db[2] - -20) < 0.1); // 0.1 = -20 dB
|
|
360
|
+
assert.ok(Math.abs(db[3] - -40) < 0.1); // 0.01 = -40 dB
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("should compute next power of 2", () => {
|
|
364
|
+
assert.strictEqual(FftUtils.nextPowerOfTwo(100), 128);
|
|
365
|
+
assert.strictEqual(FftUtils.nextPowerOfTwo(256), 256);
|
|
366
|
+
assert.strictEqual(FftUtils.nextPowerOfTwo(1000), 1024);
|
|
367
|
+
assert.strictEqual(FftUtils.nextPowerOfTwo(2048), 2048);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("should zero-pad signal", () => {
|
|
371
|
+
const signal = new Float32Array([1, 2, 3, 4, 5]);
|
|
372
|
+
const padded = FftUtils.zeroPad(signal, 10);
|
|
373
|
+
|
|
374
|
+
assert.strictEqual(padded.length, 10);
|
|
375
|
+
assert.strictEqual(padded[0], 1);
|
|
376
|
+
assert.strictEqual(padded[4], 5);
|
|
377
|
+
assert.strictEqual(padded[5], 0);
|
|
378
|
+
assert.strictEqual(padded[9], 0);
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
describe("FFT - Edge Cases", () => {
|
|
383
|
+
it("should handle DC-only signal", () => {
|
|
384
|
+
const size = 64;
|
|
385
|
+
const fft = new FftProcessor(size);
|
|
386
|
+
|
|
387
|
+
const signal = new Float32Array(size);
|
|
388
|
+
signal.fill(1.0); // DC only
|
|
389
|
+
|
|
390
|
+
const spectrum = fft.rfft(signal);
|
|
391
|
+
const magnitudes = fft.getMagnitude(spectrum);
|
|
392
|
+
|
|
393
|
+
// All energy should be in DC bin
|
|
394
|
+
assert.ok(magnitudes[0] > size * 0.9);
|
|
395
|
+
for (let i = 1; i < magnitudes.length; i++) {
|
|
396
|
+
assert.ok(magnitudes[i] < 0.1);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("should handle Nyquist frequency", () => {
|
|
401
|
+
const size = 128;
|
|
402
|
+
const fft = new FftProcessor(size);
|
|
403
|
+
|
|
404
|
+
// Alternating +1, -1 = Nyquist frequency
|
|
405
|
+
const signal = new Float32Array(size);
|
|
406
|
+
for (let i = 0; i < size; i++) {
|
|
407
|
+
signal[i] = i % 2 === 0 ? 1 : -1;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const spectrum = fft.rfft(signal);
|
|
411
|
+
const magnitudes = fft.getMagnitude(spectrum);
|
|
412
|
+
|
|
413
|
+
// Peak should be at Nyquist bin (last bin)
|
|
414
|
+
let peakBin = 0;
|
|
415
|
+
let peakValue = 0;
|
|
416
|
+
for (let i = 0; i < magnitudes.length; i++) {
|
|
417
|
+
if (magnitudes[i] > peakValue) {
|
|
418
|
+
peakValue = magnitudes[i];
|
|
419
|
+
peakBin = i;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
assert.strictEqual(peakBin, magnitudes.length - 1);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it("should handle zero signal", () => {
|
|
427
|
+
const size = 256;
|
|
428
|
+
const fft = new FftProcessor(size);
|
|
429
|
+
|
|
430
|
+
const signal = new Float32Array(size);
|
|
431
|
+
// All zeros
|
|
432
|
+
|
|
433
|
+
const spectrum = fft.rfft(signal);
|
|
434
|
+
const magnitudes = fft.getMagnitude(spectrum);
|
|
435
|
+
|
|
436
|
+
for (let i = 0; i < magnitudes.length; i++) {
|
|
437
|
+
assert.ok(Math.abs(magnitudes[i]) < 1e-6);
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe("FFT - Hermitian Symmetry", () => {
|
|
443
|
+
it("should exhibit Hermitian symmetry for real inputs", () => {
|
|
444
|
+
const size = 256;
|
|
445
|
+
const fft = new FftProcessor(size);
|
|
446
|
+
|
|
447
|
+
const signal = new Float32Array(size);
|
|
448
|
+
for (let i = 0; i < size; i++) {
|
|
449
|
+
signal[i] =
|
|
450
|
+
Math.sin((2 * Math.PI * 7 * i) / size) +
|
|
451
|
+
Math.cos((2 * Math.PI * 13 * i) / size);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// Full complex FFT
|
|
455
|
+
const complexInput: ComplexArray = {
|
|
456
|
+
real: signal,
|
|
457
|
+
imag: new Float32Array(size),
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const fullSpectrum = fft.fft(complexInput);
|
|
461
|
+
|
|
462
|
+
// Check X[k] = conj(X[N-k])
|
|
463
|
+
// Hermitian symmetry: Real[k] = Real[N-k], Imag[k] = -Imag[N-k]
|
|
464
|
+
for (let k = 1; k < size / 2; k++) {
|
|
465
|
+
const realDiff = Math.abs(
|
|
466
|
+
fullSpectrum.real[k] - fullSpectrum.real[size - k]
|
|
467
|
+
);
|
|
468
|
+
const imagDiff = Math.abs(
|
|
469
|
+
fullSpectrum.imag[k] + fullSpectrum.imag[size - k]
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
// Use relative tolerance for better numerical stability
|
|
473
|
+
const tolerance = 1e-4;
|
|
474
|
+
assert.ok(
|
|
475
|
+
realDiff < tolerance,
|
|
476
|
+
`Real part symmetry at k=${k}: diff=${realDiff}`
|
|
477
|
+
);
|
|
478
|
+
assert.ok(
|
|
479
|
+
imagDiff < tolerance,
|
|
480
|
+
`Imag part symmetry at k=${k}: diff=${imagDiff}`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
});
|
|
484
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import { createDspPipeline } from "../bindings.js";
|
|
4
|
+
|
|
5
|
+
describe("listState Method", () => {
|
|
6
|
+
it("should return basic pipeline summary before processing", () => {
|
|
7
|
+
const pipeline = createDspPipeline()
|
|
8
|
+
.MovingAverage({ mode: "moving", windowSize: 10 })
|
|
9
|
+
.Rms({ mode: "moving", windowSize: 5 });
|
|
10
|
+
|
|
11
|
+
const summary = pipeline.listState();
|
|
12
|
+
|
|
13
|
+
assert.strictEqual(summary.stageCount, 2);
|
|
14
|
+
assert.ok(summary.timestamp > 0);
|
|
15
|
+
assert.strictEqual(summary.stages.length, 2);
|
|
16
|
+
|
|
17
|
+
// Check first stage
|
|
18
|
+
assert.strictEqual(summary.stages[0].index, 0);
|
|
19
|
+
assert.strictEqual(summary.stages[0].type, "movingAverage");
|
|
20
|
+
assert.strictEqual(summary.stages[0].windowSize, 10);
|
|
21
|
+
|
|
22
|
+
// Check second stage
|
|
23
|
+
assert.strictEqual(summary.stages[1].index, 1);
|
|
24
|
+
assert.strictEqual(summary.stages[1].type, "rms");
|
|
25
|
+
assert.strictEqual(summary.stages[1].windowSize, 5);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("should include channel info after processing", async () => {
|
|
29
|
+
const pipeline = createDspPipeline().MovingAverage({ mode: "moving", windowSize: 5 });
|
|
30
|
+
|
|
31
|
+
const input = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
|
32
|
+
await pipeline.process(input, { sampleRate: 1000, channels: 1 });
|
|
33
|
+
|
|
34
|
+
const summary = pipeline.listState();
|
|
35
|
+
|
|
36
|
+
assert.strictEqual(summary.stages[0].numChannels, 1);
|
|
37
|
+
assert.strictEqual(summary.stages[0].bufferSize, 5);
|
|
38
|
+
assert.strictEqual(summary.stages[0].channelCount, 1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("should show correct channel count for multi-channel processing", async () => {
|
|
42
|
+
const pipeline = createDspPipeline()
|
|
43
|
+
.MovingAverage({ mode: "moving", windowSize: 10 })
|
|
44
|
+
.Rms({ mode: "moving", windowSize: 5 });
|
|
45
|
+
|
|
46
|
+
// 4-channel interleaved data
|
|
47
|
+
const input = new Float32Array(400).map((_, i) => Math.sin(i * 0.1));
|
|
48
|
+
await pipeline.process(input, { sampleRate: 2000, channels: 4 });
|
|
49
|
+
|
|
50
|
+
const summary = pipeline.listState();
|
|
51
|
+
|
|
52
|
+
assert.strictEqual(summary.stages[0].channelCount, 4);
|
|
53
|
+
assert.strictEqual(summary.stages[1].channelCount, 4);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("should include mode for rectify stage", () => {
|
|
57
|
+
const pipeline = createDspPipeline()
|
|
58
|
+
.Rectify({ mode: "half" })
|
|
59
|
+
.Rms({ mode: "moving", windowSize: 10 });
|
|
60
|
+
|
|
61
|
+
const summary = pipeline.listState();
|
|
62
|
+
|
|
63
|
+
assert.strictEqual(summary.stages[0].type, "rectify");
|
|
64
|
+
assert.strictEqual(summary.stages[0].mode, "half");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("should not include buffer data (unlike saveState)", async () => {
|
|
68
|
+
const pipeline = createDspPipeline().MovingAverage({ mode: "moving", windowSize: 5 });
|
|
69
|
+
|
|
70
|
+
const input = new Float32Array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
|
|
71
|
+
await pipeline.process(input, { sampleRate: 1000, channels: 1 });
|
|
72
|
+
|
|
73
|
+
const summary = pipeline.listState();
|
|
74
|
+
const summaryJson = JSON.stringify(summary);
|
|
75
|
+
|
|
76
|
+
// Should not contain actual buffer values
|
|
77
|
+
assert.ok(!summaryJson.includes('"buffer":['));
|
|
78
|
+
|
|
79
|
+
// Should not contain running sum
|
|
80
|
+
assert.ok(!summaryJson.includes('"runningSum"'));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("should be smaller than saveState", async () => {
|
|
84
|
+
const pipeline = createDspPipeline()
|
|
85
|
+
.MovingAverage({ mode: "moving", windowSize: 100 })
|
|
86
|
+
.Rms({ mode: "moving", windowSize: 50 });
|
|
87
|
+
|
|
88
|
+
const input = new Float32Array(1000).map((_, i) => Math.sin(i * 0.1));
|
|
89
|
+
await pipeline.process(input, { sampleRate: 1000, channels: 1 });
|
|
90
|
+
|
|
91
|
+
const summary = pipeline.listState();
|
|
92
|
+
const fullState = await pipeline.saveState();
|
|
93
|
+
|
|
94
|
+
const summarySize = JSON.stringify(summary).length;
|
|
95
|
+
const fullStateSize = fullState.length;
|
|
96
|
+
|
|
97
|
+
// listState should be significantly smaller
|
|
98
|
+
assert.ok(
|
|
99
|
+
summarySize < fullStateSize,
|
|
100
|
+
`Summary size (${summarySize}) should be less than full state (${fullStateSize})`
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// Should be at least 50% smaller for this case
|
|
104
|
+
const reduction = 1 - summarySize / fullStateSize;
|
|
105
|
+
assert.ok(
|
|
106
|
+
reduction > 0.5,
|
|
107
|
+
`Reduction should be > 50%, got ${(reduction * 100).toFixed(1)}%`
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("should work with complex pipeline", async () => {
|
|
112
|
+
const pipeline = createDspPipeline()
|
|
113
|
+
.MovingAverage({ mode: "moving", windowSize: 20 })
|
|
114
|
+
.Rectify({ mode: "full" })
|
|
115
|
+
.Rms({ mode: "moving", windowSize: 10 })
|
|
116
|
+
.MovingAverage({ mode: "moving", windowSize: 5 });
|
|
117
|
+
|
|
118
|
+
const input = new Float32Array(100).map((_, i) => Math.sin(i * 0.1));
|
|
119
|
+
await pipeline.process(input, { sampleRate: 1000, channels: 1 });
|
|
120
|
+
|
|
121
|
+
const summary = pipeline.listState();
|
|
122
|
+
|
|
123
|
+
assert.strictEqual(summary.stageCount, 4);
|
|
124
|
+
assert.strictEqual(summary.stages[0].type, "movingAverage");
|
|
125
|
+
assert.strictEqual(summary.stages[1].type, "rectify");
|
|
126
|
+
assert.strictEqual(summary.stages[2].type, "rms");
|
|
127
|
+
assert.strictEqual(summary.stages[3].type, "movingAverage");
|
|
128
|
+
|
|
129
|
+
// Verify each stage has expected properties
|
|
130
|
+
assert.strictEqual(summary.stages[0].windowSize, 20);
|
|
131
|
+
assert.strictEqual(summary.stages[1].mode, "full");
|
|
132
|
+
assert.strictEqual(summary.stages[2].windowSize, 10);
|
|
133
|
+
assert.strictEqual(summary.stages[3].windowSize, 5);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("should update timestamp on each call", async () => {
|
|
137
|
+
const pipeline = createDspPipeline().MovingAverage({ mode: "moving", windowSize: 5 });
|
|
138
|
+
|
|
139
|
+
const summary1 = pipeline.listState();
|
|
140
|
+
const timestamp1 = summary1.timestamp;
|
|
141
|
+
|
|
142
|
+
// Wait a moment
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
144
|
+
|
|
145
|
+
const summary2 = pipeline.listState();
|
|
146
|
+
const timestamp2 = summary2.timestamp;
|
|
147
|
+
|
|
148
|
+
assert.ok(
|
|
149
|
+
timestamp2 >= timestamp1,
|
|
150
|
+
"Second timestamp should be >= first timestamp"
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
});
|