@octoseq/mir 0.1.0-main.4ecb074 → 0.1.0-main.5fdb072

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.
Files changed (47) hide show
  1. package/dist/chunk-HF3QHCRK.js +3234 -0
  2. package/dist/chunk-HF3QHCRK.js.map +1 -0
  3. package/dist/index.d.ts +2122 -47
  4. package/dist/index.js +3524 -39
  5. package/dist/index.js.map +1 -1
  6. package/dist/runMir-CnJQbBr8.d.ts +187 -0
  7. package/dist/runner/runMir.d.ts +2 -2
  8. package/dist/runner/runMir.js +1 -1
  9. package/dist/runner/workerProtocol.d.ts +8 -1
  10. package/dist/runner/workerProtocol.js.map +1 -1
  11. package/dist/types-DqH4umN8.d.ts +761 -0
  12. package/package.json +2 -2
  13. package/src/dsp/activity.ts +544 -0
  14. package/src/dsp/bandCqt.ts +662 -0
  15. package/src/dsp/bandEvents.ts +351 -0
  16. package/src/dsp/bandMask.ts +225 -0
  17. package/src/dsp/bandMir.ts +524 -0
  18. package/src/dsp/bandProposal.ts +552 -0
  19. package/src/dsp/beatCandidates.ts +299 -0
  20. package/src/dsp/cqt.ts +386 -0
  21. package/src/dsp/cqtSignals.ts +462 -0
  22. package/src/dsp/customSignalReduction.ts +841 -0
  23. package/src/dsp/eventToSignal.ts +531 -0
  24. package/src/dsp/frequencyBand.ts +956 -0
  25. package/src/dsp/mel.ts +56 -3
  26. package/src/dsp/musicalTime.ts +240 -0
  27. package/src/dsp/onset.ts +296 -30
  28. package/src/dsp/peakPicking.ts +519 -0
  29. package/src/dsp/phaseAlignment.ts +153 -0
  30. package/src/dsp/pitch.ts +289 -0
  31. package/src/dsp/resample.ts +44 -0
  32. package/src/dsp/signalTransforms.ts +660 -0
  33. package/src/dsp/silenceGating.ts +511 -0
  34. package/src/dsp/spectral.ts +124 -4
  35. package/src/dsp/tempoHypotheses.ts +395 -0
  36. package/src/gpu/bufferPool.ts +266 -0
  37. package/src/gpu/context.ts +30 -6
  38. package/src/gpu/helpers.ts +85 -0
  39. package/src/gpu/melProject.ts +83 -0
  40. package/src/index.ts +366 -5
  41. package/src/runner/runMir.ts +234 -2
  42. package/src/runner/workerProtocol.ts +9 -1
  43. package/src/types.ts +768 -3
  44. package/dist/chunk-DUWYCAVG.js +0 -1525
  45. package/dist/chunk-DUWYCAVG.js.map +0 -1
  46. package/dist/runMir-CSIBwNZ3.d.ts +0 -84
  47. package/dist/types-BE3py4fZ.d.ts +0 -83
@@ -0,0 +1,511 @@
1
+ /**
2
+ * Silence-aware gating for onset detection.
3
+ *
4
+ * This module implements a production-grade silence detection and activity masking
5
+ * pipeline. Silence is treated as a first-class state, not "low energy sound".
6
+ *
7
+ * Pipeline stages:
8
+ * 1. Frame-level energy computation
9
+ * 2. Adaptive noise floor estimation (percentile-based)
10
+ * 3. Activity mask with hysteresis and hangover
11
+ * 4. Post-silence suppression window
12
+ *
13
+ * Design principles:
14
+ * - No fixed absolute thresholds - all thresholds are relative to estimated noise floor
15
+ * - Hysteresis prevents oscillation at threshold boundaries
16
+ * - Hangover prevents premature exit from active state
17
+ * - Post-silence suppression prevents the first audible frame from being an onset
18
+ */
19
+
20
+ /**
21
+ * Configuration for silence-aware gating.
22
+ */
23
+ export type SilenceGateConfig = {
24
+ /**
25
+ * Whether gating is enabled. If false, all frames are considered active.
26
+ * @default true
27
+ */
28
+ enabled?: boolean;
29
+
30
+ /**
31
+ * Percentile (0-100) used to estimate the noise floor from frame energies.
32
+ * Lower values are more conservative (estimate floor from quieter frames).
33
+ * @default 10
34
+ */
35
+ energyPercentile?: number;
36
+
37
+ /**
38
+ * Margin above noise floor (in the same units as frame energy) to enter active state.
39
+ * For log-scale energies (mel), this is effectively dB.
40
+ * @default 6 (approximately 6 dB above floor for mel-based energy)
41
+ */
42
+ enterMargin?: number;
43
+
44
+ /**
45
+ * Margin above noise floor to remain in active state.
46
+ * Must be less than enterMargin for proper hysteresis.
47
+ * @default 3 (approximately 3 dB above floor)
48
+ */
49
+ exitMargin?: number;
50
+
51
+ /**
52
+ * Duration in milliseconds to remain active after energy drops below exit threshold.
53
+ * Prevents premature exit during brief dips.
54
+ * @default 50
55
+ */
56
+ hangoverMs?: number;
57
+
58
+ /**
59
+ * Minimum duration in milliseconds a region must be active before allowing onsets.
60
+ * @default 0
61
+ */
62
+ minActiveMs?: number;
63
+
64
+ /**
65
+ * Duration in milliseconds after transitioning from inactive to active
66
+ * during which onsets are suppressed.
67
+ * Prevents the first audible frame from being detected as an onset.
68
+ * @default 50
69
+ */
70
+ postSilenceSuppressMs?: number;
71
+ };
72
+
73
+ /**
74
+ * Configuration for bin-level gating (CPU paths only).
75
+ * Ignores bins whose energy is below a relative floor.
76
+ */
77
+ export type BinGateConfig = {
78
+ /**
79
+ * Whether bin-level gating is enabled.
80
+ * @default true
81
+ */
82
+ enabled?: boolean;
83
+
84
+ /**
85
+ * Relative floor as a fraction of frame energy.
86
+ * Bins with value < frameEnergy * binFloorRel are ignored.
87
+ * @default 0.05
88
+ */
89
+ binFloorRel?: number;
90
+ };
91
+
92
+ /**
93
+ * Result of silence gating analysis.
94
+ * Useful for debugging and visualization.
95
+ */
96
+ export type SilenceGateResult = {
97
+ /** Per-frame energy values used for gating. */
98
+ frameEnergy: Float32Array;
99
+
100
+ /** Estimated noise floor value. */
101
+ noiseFloor: number;
102
+
103
+ /** Threshold for entering active state. */
104
+ enterThreshold: number;
105
+
106
+ /** Threshold for exiting active state. */
107
+ exitThreshold: number;
108
+
109
+ /** Per-frame activity mask (true = active). */
110
+ activityMask: Uint8Array;
111
+
112
+ /** Per-frame onset suppression mask (true = suppress onset). */
113
+ suppressionMask: Uint8Array;
114
+ };
115
+
116
+ // Default configuration values
117
+ const DEFAULT_SILENCE_GATE_CONFIG: Required<SilenceGateConfig> = {
118
+ enabled: false, // Disabled by default to avoid breaking existing behavior
119
+ energyPercentile: 10,
120
+ enterMargin: 6,
121
+ exitMargin: 3,
122
+ hangoverMs: 50,
123
+ minActiveMs: 0,
124
+ postSilenceSuppressMs: 50,
125
+ };
126
+
127
+ const DEFAULT_BIN_GATE_CONFIG: Required<BinGateConfig> = {
128
+ enabled: true,
129
+ binFloorRel: 0.05,
130
+ };
131
+
132
+ /**
133
+ * Merge user config with defaults.
134
+ */
135
+ export function withSilenceGateDefaults(config?: SilenceGateConfig): Required<SilenceGateConfig> {
136
+ return {
137
+ enabled: config?.enabled ?? DEFAULT_SILENCE_GATE_CONFIG.enabled,
138
+ energyPercentile: config?.energyPercentile ?? DEFAULT_SILENCE_GATE_CONFIG.energyPercentile,
139
+ enterMargin: config?.enterMargin ?? DEFAULT_SILENCE_GATE_CONFIG.enterMargin,
140
+ exitMargin: config?.exitMargin ?? DEFAULT_SILENCE_GATE_CONFIG.exitMargin,
141
+ hangoverMs: config?.hangoverMs ?? DEFAULT_SILENCE_GATE_CONFIG.hangoverMs,
142
+ minActiveMs: config?.minActiveMs ?? DEFAULT_SILENCE_GATE_CONFIG.minActiveMs,
143
+ postSilenceSuppressMs: config?.postSilenceSuppressMs ?? DEFAULT_SILENCE_GATE_CONFIG.postSilenceSuppressMs,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Merge user config with defaults for bin gating.
149
+ */
150
+ export function withBinGateDefaults(config?: BinGateConfig): Required<BinGateConfig> {
151
+ return {
152
+ enabled: config?.enabled ?? DEFAULT_BIN_GATE_CONFIG.enabled,
153
+ binFloorRel: config?.binFloorRel ?? DEFAULT_BIN_GATE_CONFIG.binFloorRel,
154
+ };
155
+ }
156
+
157
+ /**
158
+ * Compute per-frame energy from mel spectrogram.
159
+ *
160
+ * Since mel bands are already log10(eps + energy), we compute the mean
161
+ * of the log-energy values. This gives us a perceptually-relevant
162
+ * frame energy proxy in log space.
163
+ *
164
+ * @param melBands - Array of frames, each containing mel band values (log10 scale)
165
+ * @returns Per-frame energy values (log scale)
166
+ */
167
+ export function computeFrameEnergyFromMel(melBands: Float32Array[]): Float32Array {
168
+ const nFrames = melBands.length;
169
+ const energy = new Float32Array(nFrames);
170
+
171
+ for (let t = 0; t < nFrames; t++) {
172
+ const bands = melBands[t];
173
+ if (!bands || bands.length === 0) {
174
+ energy[t] = -100; // Very low energy for empty frames
175
+ continue;
176
+ }
177
+
178
+ // Mean of log energies (equivalent to log of geometric mean of linear energies)
179
+ let sum = 0;
180
+ for (let m = 0; m < bands.length; m++) {
181
+ sum += bands[m] ?? -100;
182
+ }
183
+ energy[t] = sum / bands.length;
184
+ }
185
+
186
+ return energy;
187
+ }
188
+
189
+ /**
190
+ * Compute per-frame energy from linear magnitude spectrogram.
191
+ *
192
+ * Uses mean magnitude (or power) across bins as the energy proxy.
193
+ *
194
+ * @param magnitudes - Array of frames, each containing magnitude values (linear scale)
195
+ * @param usePower - If true, use sum of squared magnitudes (power). Default: false (RMS-like).
196
+ * @returns Per-frame energy values (linear scale)
197
+ */
198
+ export function computeFrameEnergyFromSpectrogram(
199
+ magnitudes: Float32Array[],
200
+ usePower = false
201
+ ): Float32Array {
202
+ const nFrames = magnitudes.length;
203
+ const energy = new Float32Array(nFrames);
204
+
205
+ for (let t = 0; t < nFrames; t++) {
206
+ const mags = magnitudes[t];
207
+ if (!mags || mags.length === 0) {
208
+ energy[t] = 0;
209
+ continue;
210
+ }
211
+
212
+ let sum = 0;
213
+ for (let k = 0; k < mags.length; k++) {
214
+ const m = mags[k] ?? 0;
215
+ sum += usePower ? m * m : m;
216
+ }
217
+ energy[t] = sum / mags.length;
218
+ }
219
+
220
+ return energy;
221
+ }
222
+
223
+ /**
224
+ * Compute percentile of values.
225
+ *
226
+ * @param values - Input array
227
+ * @param percentile - Percentile to compute (0-100)
228
+ * @returns The percentile value
229
+ */
230
+ export function computePercentile(values: Float32Array, percentile: number): number {
231
+ if (values.length === 0) return 0;
232
+
233
+ // Create a sorted copy
234
+ const sorted = Float32Array.from(values).sort((a, b) => a - b);
235
+
236
+ // Clamp percentile
237
+ const p = Math.max(0, Math.min(100, percentile));
238
+ const idx = (p / 100) * (sorted.length - 1);
239
+ const lower = Math.floor(idx);
240
+ const upper = Math.ceil(idx);
241
+
242
+ if (lower === upper) {
243
+ return sorted[lower] ?? 0;
244
+ }
245
+
246
+ // Linear interpolation
247
+ const frac = idx - lower;
248
+ const lowVal = sorted[lower] ?? 0;
249
+ const highVal = sorted[upper] ?? 0;
250
+ return lowVal + frac * (highVal - lowVal);
251
+ }
252
+
253
+ /**
254
+ * Estimate adaptive noise floor from frame energies.
255
+ *
256
+ * Uses a robust percentile-based estimation rather than absolute thresholds.
257
+ *
258
+ * @param frameEnergy - Per-frame energy values
259
+ * @param percentile - Percentile to use for floor estimation (0-100)
260
+ * @returns Estimated noise floor
261
+ */
262
+ export function estimateNoiseFloor(frameEnergy: Float32Array, percentile: number): number {
263
+ return computePercentile(frameEnergy, percentile);
264
+ }
265
+
266
+ /**
267
+ * Build activity mask with hysteresis and hangover.
268
+ *
269
+ * State machine:
270
+ * - INACTIVE: energy < enterThreshold -> stay inactive
271
+ * - INACTIVE: energy >= enterThreshold -> enter ACTIVE
272
+ * - ACTIVE: energy >= exitThreshold -> stay active, reset hangover counter
273
+ * - ACTIVE: energy < exitThreshold -> decrement hangover counter
274
+ * - ACTIVE: hangover counter exhausted -> enter INACTIVE
275
+ *
276
+ * @param frameEnergy - Per-frame energy values
277
+ * @param enterThreshold - Threshold to enter active state
278
+ * @param exitThreshold - Threshold to remain in active state
279
+ * @param hangoverFrames - Number of frames to remain active after dropping below exit threshold
280
+ * @returns Activity mask (1 = active, 0 = inactive)
281
+ */
282
+ export function buildActivityMask(
283
+ frameEnergy: Float32Array,
284
+ enterThreshold: number,
285
+ exitThreshold: number,
286
+ hangoverFrames: number
287
+ ): Uint8Array {
288
+ const nFrames = frameEnergy.length;
289
+ const mask = new Uint8Array(nFrames);
290
+
291
+ let isActive = false;
292
+ let hangoverRemaining = 0;
293
+
294
+ for (let t = 0; t < nFrames; t++) {
295
+ const e = frameEnergy[t] ?? 0;
296
+
297
+ if (!isActive) {
298
+ // Inactive state: check if we should enter active
299
+ if (e >= enterThreshold) {
300
+ isActive = true;
301
+ hangoverRemaining = hangoverFrames;
302
+ mask[t] = 1;
303
+ } else {
304
+ mask[t] = 0;
305
+ }
306
+ } else {
307
+ // Active state: check if we should remain active
308
+ if (e >= exitThreshold) {
309
+ // Energy is above exit threshold, reset hangover
310
+ hangoverRemaining = hangoverFrames;
311
+ mask[t] = 1;
312
+ } else if (hangoverRemaining > 0) {
313
+ // Energy dropped but hangover not exhausted
314
+ hangoverRemaining--;
315
+ mask[t] = 1;
316
+ } else {
317
+ // Hangover exhausted, enter inactive
318
+ isActive = false;
319
+ mask[t] = 0;
320
+ }
321
+ }
322
+ }
323
+
324
+ return mask;
325
+ }
326
+
327
+ /**
328
+ * Build suppression mask for post-silence onset suppression.
329
+ *
330
+ * When transitioning from inactive to active, suppress onsets for a window
331
+ * to prevent the first audible frame from being detected as an onset.
332
+ *
333
+ * @param activityMask - Activity mask from buildActivityMask
334
+ * @param suppressFrames - Number of frames to suppress after entering active state
335
+ * @returns Suppression mask (1 = suppress onset, 0 = allow onset)
336
+ */
337
+ export function buildSuppressionMask(
338
+ activityMask: Uint8Array,
339
+ suppressFrames: number
340
+ ): Uint8Array {
341
+ const nFrames = activityMask.length;
342
+ const suppress = new Uint8Array(nFrames);
343
+
344
+ let suppressRemaining = 0;
345
+ let wasActive = false;
346
+
347
+ for (let t = 0; t < nFrames; t++) {
348
+ const isActive = (activityMask[t] ?? 0) === 1;
349
+
350
+ if (isActive && !wasActive) {
351
+ // Just transitioned from inactive to active
352
+ suppressRemaining = suppressFrames;
353
+ }
354
+
355
+ if (isActive && suppressRemaining > 0) {
356
+ suppress[t] = 1;
357
+ suppressRemaining--;
358
+ } else {
359
+ suppress[t] = 0;
360
+ }
361
+
362
+ wasActive = isActive;
363
+ }
364
+
365
+ return suppress;
366
+ }
367
+
368
+ /**
369
+ * Apply minimum active duration requirement.
370
+ *
371
+ * Regions that are active for less than minActiveFrames are set to inactive.
372
+ *
373
+ * @param activityMask - Activity mask to modify (in place)
374
+ * @param minActiveFrames - Minimum number of consecutive active frames required
375
+ */
376
+ export function applyMinActiveDuration(
377
+ activityMask: Uint8Array,
378
+ minActiveFrames: number
379
+ ): void {
380
+ if (minActiveFrames <= 1) return;
381
+
382
+ const nFrames = activityMask.length;
383
+
384
+ // Find all active regions and their lengths
385
+ type Region = { start: number; end: number };
386
+ const regions: Region[] = [];
387
+ let regionStart = -1;
388
+
389
+ for (let t = 0; t <= nFrames; t++) {
390
+ const isActive = t < nFrames && (activityMask[t] ?? 0) === 1;
391
+
392
+ if (isActive && regionStart < 0) {
393
+ regionStart = t;
394
+ } else if (!isActive && regionStart >= 0) {
395
+ regions.push({ start: regionStart, end: t });
396
+ regionStart = -1;
397
+ }
398
+ }
399
+
400
+ // Suppress regions that are too short
401
+ for (const region of regions) {
402
+ const length = region.end - region.start;
403
+ if (length < minActiveFrames) {
404
+ for (let t = region.start; t < region.end; t++) {
405
+ activityMask[t] = 0;
406
+ }
407
+ }
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Full silence gating analysis.
413
+ *
414
+ * Computes frame energy, noise floor, thresholds, activity mask, and suppression mask.
415
+ *
416
+ * @param frameEnergy - Per-frame energy values
417
+ * @param frameDurationSec - Duration of one frame in seconds
418
+ * @param config - Silence gate configuration
419
+ * @returns Complete gating result including all intermediate values
420
+ */
421
+ export function computeSilenceGating(
422
+ frameEnergy: Float32Array,
423
+ frameDurationSec: number,
424
+ config?: SilenceGateConfig
425
+ ): SilenceGateResult {
426
+ const cfg = withSilenceGateDefaults(config);
427
+
428
+ const nFrames = frameEnergy.length;
429
+
430
+ // If gating is disabled, return all-active mask
431
+ if (!cfg.enabled) {
432
+ return {
433
+ frameEnergy,
434
+ noiseFloor: -Infinity,
435
+ enterThreshold: -Infinity,
436
+ exitThreshold: -Infinity,
437
+ activityMask: new Uint8Array(nFrames).fill(1),
438
+ suppressionMask: new Uint8Array(nFrames), // All zeros = no suppression
439
+ };
440
+ }
441
+
442
+ // Step 1: Estimate noise floor
443
+ const noiseFloor = estimateNoiseFloor(frameEnergy, cfg.energyPercentile);
444
+
445
+ // Step 2: Compute thresholds
446
+ const enterThreshold = noiseFloor + cfg.enterMargin;
447
+ const exitThreshold = noiseFloor + cfg.exitMargin;
448
+
449
+ // Step 3: Convert time parameters to frames
450
+ const hangoverFrames = Math.max(0, Math.round(cfg.hangoverMs / 1000 / frameDurationSec));
451
+ const suppressFrames = Math.max(0, Math.round(cfg.postSilenceSuppressMs / 1000 / frameDurationSec));
452
+ const minActiveFrames = Math.max(0, Math.round(cfg.minActiveMs / 1000 / frameDurationSec));
453
+
454
+ // Step 4: Build activity mask with hysteresis
455
+ const activityMask = buildActivityMask(frameEnergy, enterThreshold, exitThreshold, hangoverFrames);
456
+
457
+ // Step 5: Apply minimum active duration
458
+ if (minActiveFrames > 1) {
459
+ applyMinActiveDuration(activityMask, minActiveFrames);
460
+ }
461
+
462
+ // Step 6: Build suppression mask
463
+ const suppressionMask = buildSuppressionMask(activityMask, suppressFrames);
464
+
465
+ return {
466
+ frameEnergy,
467
+ noiseFloor,
468
+ enterThreshold,
469
+ exitThreshold,
470
+ activityMask,
471
+ suppressionMask,
472
+ };
473
+ }
474
+
475
+ /**
476
+ * Apply silence gating to onset novelty values.
477
+ *
478
+ * Zeros out novelty values where the activity mask is inactive
479
+ * or the suppression mask is active.
480
+ *
481
+ * @param novelty - Onset novelty values (modified in place)
482
+ * @param activityMask - Activity mask (1 = active)
483
+ * @param suppressionMask - Suppression mask (1 = suppress)
484
+ */
485
+ export function applySilenceGating(
486
+ novelty: Float32Array,
487
+ activityMask: Uint8Array,
488
+ suppressionMask: Uint8Array
489
+ ): void {
490
+ const nFrames = novelty.length;
491
+
492
+ for (let t = 0; t < nFrames; t++) {
493
+ const isActive = (activityMask[t] ?? 0) === 1;
494
+ const isSuppressed = (suppressionMask[t] ?? 0) === 1;
495
+
496
+ if (!isActive || isSuppressed) {
497
+ novelty[t] = 0;
498
+ }
499
+ }
500
+ }
501
+
502
+ /**
503
+ * Compute bin floor threshold for bin-level gating.
504
+ *
505
+ * @param frameEnergy - Energy of the current frame
506
+ * @param binFloorRel - Relative floor as fraction of frame energy
507
+ * @returns Absolute bin floor threshold
508
+ */
509
+ export function computeBinFloor(frameEnergy: number, binFloorRel: number): number {
510
+ return frameEnergy * binFloorRel;
511
+ }
@@ -1,21 +1,116 @@
1
1
  import type { Spectrogram } from "./spectrogram";
2
+ import type { ActivitySignal } from "./activity";
3
+
4
+ /**
5
+ * Options for activity-aware spectral features.
6
+ */
7
+ export type SpectralActivityOptions = {
8
+ /**
9
+ * Activity signal to use for gating.
10
+ * If provided, feature values will be zeroed in inactive regions.
11
+ */
12
+ activity?: ActivitySignal;
13
+
14
+ /**
15
+ * Behavior for inactive frames.
16
+ * - "zero": Output 0 (default)
17
+ * - "hold": Hold the last active value
18
+ */
19
+ inactiveBehavior?: "zero" | "hold";
20
+ };
21
+
22
+ export type AmplitudeEnvelopeConfig = {
23
+ /** Hop size in samples (determines time resolution). Default: 512 */
24
+ hopSize?: number;
25
+ /** Window size for RMS calculation. Default: same as hopSize */
26
+ windowSize?: number;
27
+ };
28
+
29
+ export type AmplitudeEnvelopeResult = {
30
+ times: Float32Array;
31
+ values: Float32Array;
32
+ };
33
+
34
+ /**
35
+ * Amplitude envelope from raw audio samples.
36
+ *
37
+ * Computes RMS amplitude over windows of the time-domain signal.
38
+ * More efficient than spectrogram-based computation for full spectrum.
39
+ *
40
+ * @param samples - Mono audio samples
41
+ * @param sampleRate - Sample rate of the audio
42
+ * @param config - Configuration options
43
+ * @returns Times (seconds) and RMS amplitude values
44
+ */
45
+ export function amplitudeEnvelope(
46
+ samples: Float32Array,
47
+ sampleRate: number,
48
+ config?: AmplitudeEnvelopeConfig
49
+ ): AmplitudeEnvelopeResult {
50
+ const hopSize = config?.hopSize ?? 512;
51
+ const windowSize = config?.windowSize ?? hopSize;
52
+
53
+ const nFrames = Math.floor((samples.length - windowSize) / hopSize) + 1;
54
+ const times = new Float32Array(nFrames);
55
+ const values = new Float32Array(nFrames);
56
+
57
+ for (let t = 0; t < nFrames; t++) {
58
+ const start = t * hopSize;
59
+ const end = Math.min(start + windowSize, samples.length);
60
+
61
+ // RMS amplitude
62
+ let sumSq = 0;
63
+ for (let i = start; i < end; i++) {
64
+ const s = samples[i] ?? 0;
65
+ sumSq += s * s;
66
+ }
67
+ const rms = Math.sqrt(sumSq / (end - start));
68
+
69
+ times[t] = (start + windowSize / 2) / sampleRate;
70
+ values[t] = rms;
71
+ }
72
+
73
+ return { times, values };
74
+ }
2
75
 
3
76
  /**
4
77
  * Spectral centroid per frame (Hz).
5
78
  *
6
79
  * Output is aligned 1:1 with `spec.times`.
80
+ *
81
+ * When activity gating is enabled:
82
+ * - Inactive frames output 0 (or hold last valid value if configured)
83
+ * - This prevents noise-induced centroid values during silence
84
+ *
85
+ * @param spec - Input spectrogram
86
+ * @param options - Activity gating options
7
87
  */
8
- export function spectralCentroid(spec: Spectrogram): Float32Array {
88
+ export function spectralCentroid(
89
+ spec: Spectrogram,
90
+ options?: SpectralActivityOptions
91
+ ): Float32Array {
9
92
  const nFrames = spec.times.length;
10
93
  const out = new Float32Array(nFrames);
11
94
 
12
95
  const nBins = (spec.fftSize >>> 1) + 1;
13
96
  const binHz = spec.sampleRate / spec.fftSize;
14
97
 
98
+ const activity = options?.activity;
99
+ const holdBehavior = options?.inactiveBehavior === "hold";
100
+ let lastActiveValue = 0;
101
+
15
102
  for (let t = 0; t < nFrames; t++) {
103
+ // Check activity if provided
104
+ const isActive = activity ? (activity.isActive[t] ?? 0) === 1 : true;
105
+
106
+ if (!isActive) {
107
+ out[t] = holdBehavior ? lastActiveValue : 0;
108
+ continue;
109
+ }
110
+
16
111
  const mags = spec.magnitudes[t];
17
112
  if (!mags) {
18
- out[t] = 0;
113
+ out[t] = holdBehavior ? lastActiveValue : 0;
19
114
  continue;
20
115
  }
21
116
 
@@ -30,7 +125,9 @@ export function spectralCentroid(spec: Spectrogram): Float32Array {
30
125
  den += m;
31
126
  }
32
127
 
33
- out[t] = den > 0 ? num / den : 0;
128
+ const centroid = den > 0 ? num / den : 0;
129
+ out[t] = centroid;
130
+ lastActiveValue = centroid;
34
131
  }
35
132
 
36
133
  return out;
@@ -44,16 +141,39 @@ export function spectralCentroid(spec: Spectrogram): Float32Array {
44
141
  * - First frame flux is 0.
45
142
  *
46
143
  * Output is aligned 1:1 with `spec.times`.
144
+ *
145
+ * When activity gating is enabled:
146
+ * - Inactive frames output 0
147
+ * - Post-silence suppression is applied (first active frames after silence are zeroed)
148
+ * - This prevents false flux spikes at silence/sound boundaries
149
+ *
150
+ * @param spec - Input spectrogram
151
+ * @param options - Activity gating options
47
152
  */
48
- export function spectralFlux(spec: Spectrogram): Float32Array {
153
+ export function spectralFlux(
154
+ spec: Spectrogram,
155
+ options?: SpectralActivityOptions
156
+ ): Float32Array {
49
157
  const nFrames = spec.times.length;
50
158
  const out = new Float32Array(nFrames);
51
159
 
52
160
  const nBins = (spec.fftSize >>> 1) + 1;
161
+ const activity = options?.activity;
53
162
 
54
163
  let prev: Float32Array | null = null;
55
164
 
56
165
  for (let t = 0; t < nFrames; t++) {
166
+ // Check activity and suppression if provided
167
+ const isActive = activity ? (activity.isActive[t] ?? 0) === 1 : true;
168
+ const isSuppressed = activity ? (activity.suppressMask[t] ?? 0) === 1 : false;
169
+
170
+ if (!isActive || isSuppressed) {
171
+ out[t] = 0;
172
+ // Reset prev to avoid spurious flux when activity resumes
173
+ prev = null;
174
+ continue;
175
+ }
176
+
57
177
  const mags = spec.magnitudes[t];
58
178
  if (!mags) {
59
179
  out[t] = 0;