dspx 1.1.5 → 1.2.3

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.
@@ -1,6 +1,7 @@
1
1
  #pragma once
2
2
 
3
3
  #include "../IDspStage.h"
4
+ #include "../core/PeakDetection.h"
4
5
  #include <cmath>
5
6
  #include <stdexcept>
6
7
  #include <string>
@@ -11,34 +12,52 @@ namespace dsp::adapters
11
12
  /**
12
13
  * @brief Peak Detection Stage - Detects local maxima in a signal.
13
14
  *
14
- * This stage identifies peaks (local maxima) in the input signal using a simple
15
- * three-point comparison method. A peak is detected when:
16
- * 1. The previous sample is greater than the sample before it
17
- * 2. The previous sample is greater than the current sample
18
- * 3. The previous sample exceeds the threshold
15
+ * ... (docs updated) ...
19
16
  *
20
- * **Use Cases:**
21
- * - Heart rate detection (R-peaks in ECG)
22
- * - Event detection in sensor data
23
- * - Tempo detection in audio
24
- * - Spike detection in neural recordings
25
- *
26
- * **Output:**
27
- * - 1.0 at peak locations
28
- * - 0.0 elsewhere
29
- *
30
- * **State:** Maintains 2 previous samples per channel for continuity
17
+ * **Parameters:**
18
+ * - threshold: Minimum value for a peak.
19
+ * - mode: 'moving' (stateful) or 'batch' (stateless).
20
+ * - domain: 'time' or 'frequency'.
21
+ * - windowSize: (Batch mode only) Local neighborhood size (odd, >= 3). Default 3.
22
+ * - minPeakDistance: Minimum samples between peaks. Default 1.
31
23
  */
32
24
  class PeakDetectionStage : public IDspStage
33
25
  {
34
26
  public:
35
- explicit PeakDetectionStage(float threshold)
36
- : m_threshold(threshold), m_num_channels(0)
27
+ explicit PeakDetectionStage(float threshold, std::string mode = "moving", std::string domain = "time", int windowSize = 3, int minPeakDistance = 1)
28
+ : m_threshold(threshold),
29
+ m_mode(std::move(mode)),
30
+ m_domain(std::move(domain)),
31
+ m_windowSize(windowSize),
32
+ m_minPeakDistance(minPeakDistance),
33
+ m_num_channels(0)
37
34
  {
38
35
  if (threshold < 0.0f)
39
36
  {
40
37
  throw std::invalid_argument("PeakDetection: threshold must be >= 0");
41
38
  }
39
+ if (m_mode != "moving" && m_mode != "batch")
40
+ {
41
+ throw std::invalid_argument("PeakDetection: mode must be 'moving' or 'batch'");
42
+ }
43
+ if (m_domain != "time" && m_domain != "frequency")
44
+ {
45
+ throw std::invalid_argument("PeakDetection: domain must be 'time' or 'frequency'");
46
+ }
47
+ if (windowSize < 3 || windowSize % 2 == 0)
48
+ {
49
+ throw std::invalid_argument("PeakDetection: windowSize must be an odd number >= 3");
50
+ }
51
+ if (minPeakDistance < 1)
52
+ {
53
+ throw std::invalid_argument("PeakDetection: minPeakDistance must be >= 1");
54
+ }
55
+
56
+ // In moving mode, we only support the 3-point window
57
+ if (m_mode == "moving")
58
+ {
59
+ m_windowSize = 3;
60
+ }
42
61
  }
43
62
 
44
63
  const char *getType() const override
@@ -48,77 +67,43 @@ namespace dsp::adapters
48
67
 
49
68
  void process(float *buffer, size_t numSamples, int numChannels, const float *timestamps = nullptr) override
50
69
  {
51
- if (m_num_channels != numChannels || m_prev_sample.empty())
70
+ if (m_mode == "moving")
52
71
  {
53
- m_num_channels = numChannels;
54
- m_prev_sample.resize(numChannels, 0.0f);
55
- m_prev_prev_sample.resize(numChannels, 0.0f);
72
+ // Note: processMoving implicitly uses m_windowSize = 3
73
+ processMoving(buffer, numSamples, numChannels);
56
74
  }
57
-
58
- size_t samplesPerChannel = numSamples / numChannels;
59
-
60
- for (int ch = 0; ch < numChannels; ++ch)
75
+ else // m_mode == "batch"
61
76
  {
62
- float prev_prev = m_prev_prev_sample[ch];
63
- float prev = m_prev_sample[ch];
64
-
65
- for (size_t i = 0; i < samplesPerChannel; ++i)
66
- {
67
- size_t idx = i * numChannels + ch;
68
- float current = buffer[idx];
69
-
70
- // Check if `prev` (the previous sample) was a peak
71
- // We can now confirm this since we have the current sample
72
- bool prev_is_peak = (prev > prev_prev) && (prev > current) && (prev >= m_threshold);
73
-
74
- if (i > 0)
75
- {
76
- // Write peak marker BACK to the previous sample's position
77
- buffer[idx - numChannels] = prev_is_peak ? 1.0f : 0.0f;
78
- }
79
- else
80
- {
81
- // For the first sample, we mark it if the prev (from previous process call) was a peak
82
- buffer[idx] = prev_is_peak ? 1.0f : 0.0f;
83
- }
84
-
85
- // Shift history forward
86
- prev_prev = prev;
87
- prev = current;
88
- }
89
-
90
- // The last sample in the buffer can't be confirmed as a peak yet
91
- // (we need to see the next sample), so set it to 0
92
- // BUT: Don't overwrite if there's only 1 sample and we just wrote the pending result at i==0
93
- if (samplesPerChannel > 1)
94
- {
95
- buffer[(samplesPerChannel - 1) * numChannels + ch] = 0.0f;
96
- }
97
-
98
- // Save state for next process call
99
- m_prev_prev_sample[ch] = prev_prev;
100
- m_prev_sample[ch] = prev;
77
+ processBatch(buffer, numSamples, numChannels);
101
78
  }
102
79
  }
80
+
103
81
  Napi::Object serializeState(Napi::Env env) const override
104
82
  {
105
83
  Napi::Object state = Napi::Object::New(env);
106
84
  state.Set("threshold", m_threshold);
107
85
  state.Set("numChannels", m_num_channels);
86
+ state.Set("mode", m_mode);
87
+ state.Set("domain", m_domain);
88
+ state.Set("windowSize", m_windowSize);
89
+ state.Set("minPeakDistance", m_minPeakDistance);
108
90
 
109
- if (!m_prev_sample.empty())
91
+ if (m_mode == "moving" && !m_prev_sample.empty())
110
92
  {
111
93
  Napi::Array prevArray = Napi::Array::New(env, m_prev_sample.size());
112
94
  Napi::Array prevPrevArray = Napi::Array::New(env, m_prev_prev_sample.size());
95
+ Napi::Array cooldownArray = Napi::Array::New(env, m_peakCooldown.size());
113
96
 
114
97
  for (size_t i = 0; i < m_prev_sample.size(); ++i)
115
98
  {
116
99
  prevArray.Set(i, m_prev_sample[i]);
117
100
  prevPrevArray.Set(i, m_prev_prev_sample[i]);
101
+ cooldownArray.Set(i, m_peakCooldown[i]);
118
102
  }
119
103
 
120
104
  state.Set("prevSample", prevArray);
121
105
  state.Set("prevPrevSample", prevPrevArray);
106
+ state.Set("peakCooldown", cooldownArray);
122
107
  }
123
108
 
124
109
  return state;
@@ -130,19 +115,30 @@ namespace dsp::adapters
130
115
  m_threshold = state.Get("threshold").As<Napi::Number>().FloatValue();
131
116
  if (state.Has("numChannels"))
132
117
  m_num_channels = state.Get("numChannels").As<Napi::Number>().Int32Value();
118
+ if (state.Has("mode"))
119
+ m_mode = state.Get("mode").As<Napi::String>().Utf8Value();
120
+ if (state.Has("domain"))
121
+ m_domain = state.Get("domain").As<Napi::String>().Utf8Value();
122
+ if (state.Has("windowSize"))
123
+ m_windowSize = state.Get("windowSize").As<Napi::Number>().Int32Value();
124
+ if (state.Has("minPeakDistance"))
125
+ m_minPeakDistance = state.Get("minPeakDistance").As<Napi::Number>().Int32Value();
133
126
 
134
- if (state.Has("prevSample"))
127
+ if (m_mode == "moving" && state.Has("prevSample"))
135
128
  {
136
129
  Napi::Array prevArray = state.Get("prevSample").As<Napi::Array>();
137
130
  Napi::Array prevPrevArray = state.Get("prevPrevSample").As<Napi::Array>();
131
+ Napi::Array cooldownArray = state.Get("peakCooldown").As<Napi::Array>();
138
132
 
139
133
  m_prev_sample.clear();
140
134
  m_prev_prev_sample.clear();
135
+ m_peakCooldown.clear();
141
136
 
142
137
  for (size_t i = 0; i < prevArray.Length(); ++i)
143
138
  {
144
139
  m_prev_sample.push_back(prevArray.Get(i).As<Napi::Number>().FloatValue());
145
140
  m_prev_prev_sample.push_back(prevPrevArray.Get(i).As<Napi::Number>().FloatValue());
141
+ m_peakCooldown.push_back(cooldownArray.Get(i).As<Napi::Number>().Int32Value());
146
142
  }
147
143
  }
148
144
  }
@@ -151,15 +147,132 @@ namespace dsp::adapters
151
147
  {
152
148
  std::fill(m_prev_sample.begin(), m_prev_sample.end(), 0.0f);
153
149
  std::fill(m_prev_prev_sample.begin(), m_prev_prev_sample.end(), 0.0f);
150
+ std::fill(m_peakCooldown.begin(), m_peakCooldown.end(), 0);
154
151
  }
155
152
 
156
153
  bool isResizing() const override { return false; }
157
154
 
158
155
  private:
156
+ /**
157
+ * @brief Stateful ("moving") peak detection processing (WindowSize = 3 only).
158
+ */
159
+ void processMoving(float *buffer, size_t numSamples, int numChannels)
160
+ {
161
+ if (m_num_channels != numChannels || m_prev_sample.empty())
162
+ {
163
+ m_num_channels = numChannels;
164
+ m_prev_sample.resize(numChannels, 0.0f);
165
+ m_prev_prev_sample.resize(numChannels, 0.0f);
166
+ m_peakCooldown.resize(numChannels, 0); // Resize cooldown state
167
+ }
168
+
169
+ size_t samplesPerChannel = numSamples / numChannels;
170
+
171
+ for (int ch = 0; ch < numChannels; ++ch)
172
+ {
173
+ float prev_prev = m_prev_prev_sample[ch];
174
+ float prev = m_prev_sample[ch];
175
+ int &cooldown = m_peakCooldown[ch]; // Get reference to cooldown counter
176
+
177
+ for (size_t i = 0; i < samplesPerChannel; ++i)
178
+ {
179
+ size_t idx = i * numChannels + ch;
180
+ float current = buffer[idx];
181
+
182
+ // Decrement cooldown if active
183
+ if (cooldown > 0)
184
+ {
185
+ cooldown--;
186
+ }
187
+
188
+ // Check if `prev` (the previous sample) was a peak
189
+ bool prev_is_peak = (cooldown == 0) && // Must not be in cooldown
190
+ (prev > prev_prev) &&
191
+ (prev > current) &&
192
+ (prev >= m_threshold);
193
+
194
+ if (i > 0)
195
+ {
196
+ buffer[idx - numChannels] = prev_is_peak ? 1.0f : 0.0f;
197
+ }
198
+ else
199
+ {
200
+ buffer[idx] = prev_is_peak ? 1.0f : 0.0f;
201
+ }
202
+
203
+ // If we found a peak, reset the cooldown
204
+ if (prev_is_peak)
205
+ {
206
+ cooldown = m_minPeakDistance - 1;
207
+ }
208
+
209
+ // Shift history forward
210
+ prev_prev = prev;
211
+ prev = current;
212
+ }
213
+
214
+ if (samplesPerChannel > 1)
215
+ {
216
+ buffer[(samplesPerChannel - 1) * numChannels + ch] = 0.0f;
217
+ }
218
+
219
+ m_prev_prev_sample[ch] = prev_prev;
220
+ m_prev_sample[ch] = prev;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * @brief Stateless ("batch") peak detection processing.
226
+ */
227
+ void processBatch(float *buffer, size_t numSamples, int numChannels)
228
+ {
229
+ if (m_planar_input.size() < numSamples / numChannels)
230
+ {
231
+ m_planar_input.resize(numSamples / numChannels);
232
+ m_planar_output.resize(numSamples / numChannels);
233
+ }
234
+
235
+ size_t samplesPerChannel = numSamples / numChannels;
236
+
237
+ // Select the correct core function
238
+ void (*peak_func)(const float *, float *, size_t, float, int, int) =
239
+ (m_domain == "frequency")
240
+ ? dsp::core::find_freq_peaks_batch
241
+ : dsp::core::find_peaks_batch_delayed;
242
+
243
+ for (int ch = 0; ch < numChannels; ++ch)
244
+ {
245
+ for (size_t i = 0; i < samplesPerChannel; ++i)
246
+ {
247
+ m_planar_input[i] = buffer[i * numChannels + ch];
248
+ }
249
+
250
+ // Process using the core function, passing all parameters
251
+ peak_func(m_planar_input.data(), m_planar_output.data(), samplesPerChannel, m_threshold, m_windowSize, m_minPeakDistance);
252
+
253
+ for (size_t i = 0; i < samplesPerChannel; ++i)
254
+ {
255
+ buffer[i * numChannels + ch] = m_planar_output[i];
256
+ }
257
+ }
258
+ }
259
+
260
+ // --- Member Variables ---
159
261
  float m_threshold;
262
+ std::string m_mode;
263
+ std::string m_domain;
264
+ int m_windowSize;
265
+ int m_minPeakDistance;
160
266
  int m_num_channels;
267
+
268
+ // State for "moving" mode
161
269
  std::vector<float> m_prev_sample;
162
270
  std::vector<float> m_prev_prev_sample;
271
+ std::vector<int> m_peakCooldown; // Cooldown counter per channel
272
+
273
+ // Temporary planar buffers for "batch" mode
274
+ std::vector<float> m_planar_input;
275
+ std::vector<float> m_planar_output;
163
276
  };
164
277
 
165
- } // namespace dsp::adapters
278
+ } // namespace dsp::adapters
@@ -0,0 +1,225 @@
1
+ #pragma once
2
+
3
+ #include <cmath>
4
+ #include <cstddef>
5
+ #include <vector>
6
+ #include <algorithm>
7
+ #include "../utils/SimdOps.h"
8
+
9
+ namespace dsp::core
10
+ {
11
+ /**
12
+ * @brief Post-processing pass to enforce minimum peak distance.
13
+ * Modifies the output buffer in-place.
14
+ */
15
+ inline void apply_min_peak_distance(float *output, size_t size, int minPeakDistance)
16
+ {
17
+ if (minPeakDistance <= 1)
18
+ {
19
+ return;
20
+ }
21
+
22
+ int cooldown = 0;
23
+ for (size_t i = 0; i < size; ++i)
24
+ {
25
+ if (output[i] == 1.0f)
26
+ {
27
+ if (cooldown == 0)
28
+ {
29
+ // This is a valid peak, start cooldown
30
+ cooldown = minPeakDistance - 1;
31
+ }
32
+ else
33
+ {
34
+ // This peak is suppressed
35
+ output[i] = 0.0f;
36
+ cooldown--; // Still decrement cooldown
37
+ }
38
+ }
39
+ else if (cooldown > 0)
40
+ {
41
+ cooldown--;
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * @brief Scalar (flexible) delayed peak detection.
48
+ * This is the fallback for windowSize != 3.
49
+ */
50
+ inline void find_peaks_batch_scalar_delayed(const float *input, float *output, size_t size, float threshold, int windowSize)
51
+ {
52
+ // We check a peak at [i-k] when we see sample [i].
53
+ // This maintains the "delayed" logic.
54
+ const int k = (windowSize - 1) / 2;
55
+ const int end_idx = windowSize - 1;
56
+
57
+ if (size < windowSize)
58
+ {
59
+ return; // Not enough samples
60
+ }
61
+
62
+ for (size_t i = end_idx; i < size; ++i)
63
+ {
64
+ const size_t peak_idx = i - k; // The candidate peak index
65
+ const float candidate = input[peak_idx];
66
+
67
+ if (candidate < threshold)
68
+ {
69
+ output[peak_idx] = 0.0f;
70
+ continue;
71
+ }
72
+
73
+ bool is_max = true;
74
+ // Check all neighbors from [i-k-k] to [i-k+k] (which is [i-end_idx] to [i])
75
+ for (int j = -k; j <= k; ++j)
76
+ {
77
+ if (j == 0)
78
+ continue; // Don't compare to self
79
+
80
+ if (candidate <= input[peak_idx + j])
81
+ {
82
+ is_max = false;
83
+ break;
84
+ }
85
+ }
86
+
87
+ output[peak_idx] = is_max ? 1.0f : 0.0f;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * @brief Stateless (batch) peak detection using the 3-point delayed method.
93
+ *
94
+ * Finds local maxima in a buffer.
95
+ *
96
+ * @param input The input signal buffer.
97
+ * @param output The output buffer (will be filled with 1.0 at peaks, 0.0 otherwise).
98
+ * @param size The number of samples in the input/output buffers.
99
+ * @param threshold The minimum value for a peak to be considered.
100
+ * @param windowSize The local neighborhood size (must be odd, >= 3).
101
+ * @param minPeakDistance The minimum samples between peaks.
102
+ *
103
+ * @note Output edges (0..k and size-k..size) will be 0.
104
+ */
105
+ inline void find_peaks_batch_delayed(const float *input, float *output, size_t size, float threshold, int windowSize, int minPeakDistance)
106
+ {
107
+ // Initialize output to 0.0
108
+ std::fill(output, output + size, 0.0f);
109
+
110
+ if (windowSize < 3 || windowSize % 2 == 0)
111
+ {
112
+ return; // Invalid window size
113
+ }
114
+
115
+ if (size < windowSize)
116
+ {
117
+ return; // Not enough samples
118
+ }
119
+
120
+ if (windowSize == 3)
121
+ {
122
+ // --- Optimized SIMD Path for windowSize = 3 ---
123
+ size_t i = 2;
124
+
125
+ #if defined(SIMD_AVX2)
126
+ const size_t simd_width = 8;
127
+ const size_t simd_limit = (size >= simd_width + 2) ? (size - simd_width) : 0;
128
+ const __m256 v_thresh = _mm256_set1_ps(threshold);
129
+ const __m256 v_ones = _mm256_set1_ps(1.0f);
130
+
131
+ for (i = 2; i <= simd_limit; i += simd_width)
132
+ {
133
+ __m256 v_prev_prev = _mm256_loadu_ps(&input[i - 2]);
134
+ __m256 v_prev = _mm256_loadu_ps(&input[i - 1]);
135
+ __m256 v_current = _mm256_loadu_ps(&input[i]);
136
+
137
+ __m256 mask1 = _mm256_cmp_ps(v_prev, v_prev_prev, _CMP_GT_OQ);
138
+ __m256 mask2 = _mm256_cmp_ps(v_prev, v_current, _CMP_GT_OQ);
139
+ __m256 mask3 = _mm256_cmp_ps(v_prev, v_thresh, _CMP_GE_OQ);
140
+
141
+ __m256 mask = _mm256_and_ps(_mm256_and_ps(mask1, mask2), mask3);
142
+ __m256 v_result = _mm256_and_ps(mask, v_ones);
143
+ _mm256_storeu_ps(&output[i - 1], v_result);
144
+ }
145
+ #elif defined(SIMD_SSE2)
146
+ // ... (Same SSE2 logic as before) ...
147
+ const size_t simd_width = 4;
148
+ const size_t simd_limit = (size >= simd_width + 2) ? (size - simd_width) : 0;
149
+ const __m128 v_thresh = _mm_set1_ps(threshold);
150
+ const __m128 v_ones = _mm_set1_ps(1.0f);
151
+
152
+ for (i = 2; i <= simd_limit; i += simd_width)
153
+ {
154
+ __m128 v_prev_prev = _mm_loadu_ps(&input[i - 2]);
155
+ __m128 v_prev = _mm_loadu_ps(&input[i - 1]);
156
+ __m128 v_current = _mm_loadu_ps(&input[i]);
157
+
158
+ __m128 mask1 = _mm_cmpgt_ps(v_prev, v_prev_prev);
159
+ __m128 mask2 = _mm_cmpgt_ps(v_prev, v_current);
160
+ __m128 mask3 = _mm_cmpge_ps(v_prev, v_thresh);
161
+
162
+ __m128 mask = _mm_and_ps(_mm_and_ps(mask1, mask2), mask3);
163
+ __m128 v_result = _mm_and_ps(mask, v_ones);
164
+ _mm_storeu_ps(&output[i - 1], v_result);
165
+ }
166
+ #elif defined(SIMD_NEON)
167
+ // ... (Same NEON logic as before) ...
168
+ const size_t simd_width = 4;
169
+ const size_t simd_limit = (size >= simd_width + 2) ? (size - simd_width) : 0;
170
+ const float32x4_t v_thresh = vdupq_n_f32(threshold);
171
+ const float32x4_t v_ones = vdupq_n_f32(1.0f);
172
+ const float32x4_t v_zeros = vdupq_n_f32(0.0f);
173
+
174
+ for (i = 2; i <= simd_limit; i += simd_width)
175
+ {
176
+ float32x4_t v_prev_prev = vld1q_f32(&input[i - 2]);
177
+ float32x4_t v_prev = vld1q_f32(&input[i - 1]);
178
+ float32x4_t v_current = vld1q_f32(&input[i]);
179
+
180
+ uint32x4_t mask1 = vcgtq_f32(v_prev, v_prev_prev);
181
+ uint32x4_t mask2 = vcgtq_f32(v_prev, v_current);
182
+ uint32x4_t mask3 = vcgeq_f32(v_prev, v_thresh);
183
+
184
+ uint32x4_t mask = vandq_u32(vandq_u32(mask1, mask2), mask3);
185
+ float32x4_t v_result = vbslq_f32(mask, v_ones, v_zeros);
186
+ vst1q_f32(&output[i - 1], v_result);
187
+ }
188
+ #endif
189
+ // Handle remainder for windowSize = 3
190
+ for (; i < size; ++i)
191
+ {
192
+ const float prev_prev = input[i - 2];
193
+ const float prev = input[i - 1];
194
+ const float current = input[i];
195
+
196
+ bool prev_is_peak = (prev > prev_prev) && (prev > current) && (prev >= threshold);
197
+ if (prev_is_peak)
198
+ {
199
+ output[i - 1] = 1.0f;
200
+ }
201
+ }
202
+ }
203
+ else
204
+ {
205
+ // --- Scalar Fallback Path for windowSize != 3 ---
206
+ find_peaks_batch_scalar_delayed(input, output, size, threshold, windowSize);
207
+ }
208
+
209
+ // --- Post-processing for Minimum Peak Distance ---
210
+ if (minPeakDistance > 1)
211
+ {
212
+ apply_min_peak_distance(output, size, minPeakDistance);
213
+ }
214
+ }
215
+
216
+ /**
217
+ * @brief Stateless (batch) peak detection for frequency domain.
218
+ */
219
+ inline void find_freq_peaks_batch(const float *input, float *output, size_t size, float threshold, int windowSize, int minPeakDistance)
220
+ {
221
+ // The algorithm is identical to the time-domain batch version.
222
+ find_peaks_batch_delayed(input, output, size, threshold, windowSize, minPeakDistance);
223
+ }
224
+
225
+ } // namespace dsp::core