@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@octoseq/mir",
3
- "version": "0.1.0-main.4ecb074",
3
+ "version": "0.1.0-main.5fdb072",
4
4
  "description": "WebGPU-accelerated Music Information Retrieval (MIR) library (skeleton)",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -38,7 +38,7 @@
38
38
  "@types/node": "latest",
39
39
  "eslint": "latest",
40
40
  "tsup": "latest",
41
- "typescript": "latest",
41
+ "typescript": "^5",
42
42
  "vitest": "latest"
43
43
  },
44
44
  "dependencies": {
@@ -0,0 +1,544 @@
1
+ /**
2
+ * Activity Signal - First-class MIR feature for audibility detection.
3
+ *
4
+ * The Activity Signal represents whether audible content is present at each frame.
5
+ * It is the foundation for silence-aware MIR processing across all features.
6
+ *
7
+ * Design Principles:
8
+ * 1. Silence is a distinct state, not "low energy sound"
9
+ * 2. Activity detection is shared, not reimplemented per feature
10
+ * 3. Activity is computed from whole-track context (adaptive thresholds)
11
+ * 4. The signal is inspectable and visualizable
12
+ *
13
+ * Semantics:
14
+ * - activity_level: 0 = silence/inaudible, 1 = confidently audible
15
+ * - is_active: boolean derived from activity_level
16
+ */
17
+
18
+ import type { MelSpectrogram } from "./mel";
19
+ import type { Spectrogram } from "./spectrogram";
20
+ import {
21
+ computeFrameEnergyFromMel,
22
+ computeFrameEnergyFromSpectrogram,
23
+ estimateNoiseFloor,
24
+ buildActivityMask,
25
+ buildSuppressionMask,
26
+ applyMinActiveDuration,
27
+ } from "./silenceGating";
28
+
29
+ /**
30
+ * Configuration for activity detection.
31
+ */
32
+ export type ActivityConfig = {
33
+ /**
34
+ * Percentile (0-100) used to estimate the noise floor from frame energies.
35
+ * Lower values are more conservative (estimate floor from quieter frames).
36
+ * @default 10
37
+ */
38
+ energyPercentile?: number;
39
+
40
+ /**
41
+ * Margin above noise floor to enter active state.
42
+ * For log-scale energies (mel), this is effectively dB.
43
+ * @default 6
44
+ */
45
+ enterMargin?: number;
46
+
47
+ /**
48
+ * Margin above noise floor to remain in active state.
49
+ * Must be less than enterMargin for proper hysteresis.
50
+ * @default 3
51
+ */
52
+ exitMargin?: number;
53
+
54
+ /**
55
+ * Duration in milliseconds to remain active after energy drops below exit threshold.
56
+ * Prevents rapid toggling near silence.
57
+ * @default 50
58
+ */
59
+ hangoverMs?: number;
60
+
61
+ /**
62
+ * Minimum duration in milliseconds a region must be active.
63
+ * Regions shorter than this are marked inactive.
64
+ * @default 30
65
+ */
66
+ minActiveMs?: number;
67
+
68
+ /**
69
+ * Smoothing window in milliseconds for activity_level output.
70
+ * Produces smoother transitions. 0 = no smoothing.
71
+ * @default 20
72
+ */
73
+ smoothMs?: number;
74
+ };
75
+
76
+ /**
77
+ * Activity Signal result - the main output type.
78
+ */
79
+ export type ActivitySignal = {
80
+ /** Frame times in seconds, aligned with source spectrogram/mel. */
81
+ times: Float32Array;
82
+
83
+ /**
84
+ * Continuous activity level in range [0, 1].
85
+ * 0 = silence, 1 = confidently active.
86
+ * Intermediate values occur at transitions and represent uncertainty.
87
+ */
88
+ activityLevel: Float32Array;
89
+
90
+ /**
91
+ * Binary activity mask (0 = inactive, 1 = active).
92
+ * Derived from the underlying hysteresis state machine.
93
+ */
94
+ isActive: Uint8Array;
95
+
96
+ /**
97
+ * Per-frame suppression mask (0 = allow, 1 = suppress).
98
+ * Used to suppress features immediately after silence-to-active transitions.
99
+ */
100
+ suppressMask: Uint8Array;
101
+
102
+ /** Diagnostic information for debugging and visualization. */
103
+ diagnostics: ActivityDiagnostics;
104
+ };
105
+
106
+ /**
107
+ * Diagnostics for inspecting activity detection behavior.
108
+ */
109
+ export type ActivityDiagnostics = {
110
+ /** Per-frame energy values used for detection. */
111
+ frameEnergy: Float32Array;
112
+
113
+ /** Estimated noise floor value. */
114
+ noiseFloor: number;
115
+
116
+ /** Threshold for entering active state. */
117
+ enterThreshold: number;
118
+
119
+ /** Threshold for exiting active state. */
120
+ exitThreshold: number;
121
+ };
122
+
123
+ // Default configuration values
124
+ const DEFAULT_ACTIVITY_CONFIG: Required<ActivityConfig> = {
125
+ energyPercentile: 10,
126
+ enterMargin: 6,
127
+ exitMargin: 3,
128
+ hangoverMs: 50,
129
+ minActiveMs: 30,
130
+ smoothMs: 20,
131
+ };
132
+
133
+ /**
134
+ * Merge user config with defaults.
135
+ */
136
+ export function withActivityDefaults(config?: ActivityConfig): Required<ActivityConfig> {
137
+ return {
138
+ energyPercentile: config?.energyPercentile ?? DEFAULT_ACTIVITY_CONFIG.energyPercentile,
139
+ enterMargin: config?.enterMargin ?? DEFAULT_ACTIVITY_CONFIG.enterMargin,
140
+ exitMargin: config?.exitMargin ?? DEFAULT_ACTIVITY_CONFIG.exitMargin,
141
+ hangoverMs: config?.hangoverMs ?? DEFAULT_ACTIVITY_CONFIG.hangoverMs,
142
+ minActiveMs: config?.minActiveMs ?? DEFAULT_ACTIVITY_CONFIG.minActiveMs,
143
+ smoothMs: config?.smoothMs ?? DEFAULT_ACTIVITY_CONFIG.smoothMs,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * Smooth a signal using a moving average.
149
+ */
150
+ function smoothSignal(values: Float32Array, windowFrames: number): Float32Array {
151
+ if (windowFrames <= 1) return values;
152
+
153
+ const n = values.length;
154
+ const out = new Float32Array(n);
155
+ const half = Math.floor(windowFrames / 2);
156
+
157
+ // Prefix sums for O(n) moving average
158
+ const prefix = new Float64Array(n + 1);
159
+ prefix[0] = 0;
160
+ for (let i = 0; i < n; i++) {
161
+ prefix[i + 1] = (prefix[i] ?? 0) + (values[i] ?? 0);
162
+ }
163
+
164
+ for (let i = 0; i < n; i++) {
165
+ const start = Math.max(0, i - half);
166
+ const end = Math.min(n, i + half + 1);
167
+ const sum = (prefix[end] ?? 0) - (prefix[start] ?? 0);
168
+ const count = Math.max(1, end - start);
169
+ out[i] = sum / count;
170
+ }
171
+
172
+ return out;
173
+ }
174
+
175
+ /**
176
+ * Convert binary activity mask to continuous activity level.
177
+ *
178
+ * The raw mask is 0/1. We apply smoothing to create gradual transitions
179
+ * that better represent uncertainty at boundaries.
180
+ */
181
+ function maskToActivityLevel(
182
+ mask: Uint8Array,
183
+ smoothFrames: number
184
+ ): Float32Array {
185
+ const n = mask.length;
186
+ const level = new Float32Array(n);
187
+
188
+ // Convert to float
189
+ for (let i = 0; i < n; i++) {
190
+ level[i] = mask[i] ?? 0;
191
+ }
192
+
193
+ // Apply smoothing if requested
194
+ if (smoothFrames > 1) {
195
+ return smoothSignal(level, smoothFrames);
196
+ }
197
+
198
+ return level;
199
+ }
200
+
201
+ /**
202
+ * Compute Activity Signal from mel spectrogram.
203
+ *
204
+ * This is the primary entry point for activity detection.
205
+ * Mel spectrograms are preferred because their log-scale energies
206
+ * provide more perceptually meaningful thresholds.
207
+ *
208
+ * @param mel - Mel spectrogram (log-scale energies)
209
+ * @param config - Activity detection configuration
210
+ * @returns Complete activity signal with diagnostics
211
+ */
212
+ export function computeActivityFromMel(
213
+ mel: MelSpectrogram,
214
+ config?: ActivityConfig
215
+ ): ActivitySignal {
216
+ const cfg = withActivityDefaults(config);
217
+ const nFrames = mel.times.length;
218
+
219
+ // Step 1: Compute frame energy (mean of log-mel bands)
220
+ const frameEnergy = computeFrameEnergyFromMel(mel.melBands);
221
+
222
+ // Step 2: Estimate noise floor
223
+ const noiseFloor = estimateNoiseFloor(frameEnergy, cfg.energyPercentile);
224
+
225
+ // Step 3: Compute thresholds
226
+ const enterThreshold = noiseFloor + cfg.enterMargin;
227
+ const exitThreshold = noiseFloor + cfg.exitMargin;
228
+
229
+ // Step 4: Convert time parameters to frames
230
+ const frameDurationSec = nFrames >= 2
231
+ ? (mel.times[1] ?? 0) - (mel.times[0] ?? 0)
232
+ : 0.01;
233
+
234
+ const hangoverFrames = Math.max(0, Math.round(cfg.hangoverMs / 1000 / frameDurationSec));
235
+ const minActiveFrames = Math.max(0, Math.round(cfg.minActiveMs / 1000 / frameDurationSec));
236
+ const smoothFrames = Math.max(1, Math.round(cfg.smoothMs / 1000 / frameDurationSec)) | 1; // Ensure odd
237
+
238
+ // Step 5: Build activity mask with hysteresis
239
+ const isActive = buildActivityMask(frameEnergy, enterThreshold, exitThreshold, hangoverFrames);
240
+
241
+ // Step 6: Apply minimum active duration
242
+ if (minActiveFrames > 1) {
243
+ applyMinActiveDuration(isActive, minActiveFrames);
244
+ }
245
+
246
+ // Step 7: Build suppression mask (for post-silence onset suppression)
247
+ const suppressFrames = Math.max(0, Math.round(50 / 1000 / frameDurationSec)); // Fixed 50ms
248
+ const suppressMask = buildSuppressionMask(isActive, suppressFrames);
249
+
250
+ // Step 8: Convert to continuous activity level
251
+ const activityLevel = maskToActivityLevel(isActive, smoothFrames);
252
+
253
+ return {
254
+ times: mel.times,
255
+ activityLevel,
256
+ isActive,
257
+ suppressMask,
258
+ diagnostics: {
259
+ frameEnergy,
260
+ noiseFloor,
261
+ enterThreshold,
262
+ exitThreshold,
263
+ },
264
+ };
265
+ }
266
+
267
+ /**
268
+ * Compute Activity Signal from linear magnitude spectrogram.
269
+ *
270
+ * This function first converts magnitudes to log scale for threshold computation.
271
+ *
272
+ * @param spec - Linear magnitude spectrogram
273
+ * @param config - Activity detection configuration
274
+ * @returns Complete activity signal with diagnostics
275
+ */
276
+ export function computeActivityFromSpectrogram(
277
+ spec: Spectrogram,
278
+ config?: ActivityConfig
279
+ ): ActivitySignal {
280
+ const cfg = withActivityDefaults(config);
281
+ const nFrames = spec.times.length;
282
+
283
+ // Step 1: Compute frame energy (mean magnitude) and convert to log scale
284
+ const linearEnergy = computeFrameEnergyFromSpectrogram(spec.magnitudes, false);
285
+ const frameEnergy = new Float32Array(nFrames);
286
+ const eps = 1e-12;
287
+ for (let t = 0; t < nFrames; t++) {
288
+ frameEnergy[t] = Math.log10(eps + (linearEnergy[t] ?? 0));
289
+ }
290
+
291
+ // Step 2: Estimate noise floor
292
+ const noiseFloor = estimateNoiseFloor(frameEnergy, cfg.energyPercentile);
293
+
294
+ // Step 3: Compute thresholds
295
+ const enterThreshold = noiseFloor + cfg.enterMargin;
296
+ const exitThreshold = noiseFloor + cfg.exitMargin;
297
+
298
+ // Step 4: Convert time parameters to frames
299
+ const frameDurationSec = nFrames >= 2
300
+ ? (spec.times[1] ?? 0) - (spec.times[0] ?? 0)
301
+ : 0.01;
302
+
303
+ const hangoverFrames = Math.max(0, Math.round(cfg.hangoverMs / 1000 / frameDurationSec));
304
+ const minActiveFrames = Math.max(0, Math.round(cfg.minActiveMs / 1000 / frameDurationSec));
305
+ const smoothFrames = Math.max(1, Math.round(cfg.smoothMs / 1000 / frameDurationSec)) | 1;
306
+
307
+ // Step 5: Build activity mask with hysteresis
308
+ const isActive = buildActivityMask(frameEnergy, enterThreshold, exitThreshold, hangoverFrames);
309
+
310
+ // Step 6: Apply minimum active duration
311
+ if (minActiveFrames > 1) {
312
+ applyMinActiveDuration(isActive, minActiveFrames);
313
+ }
314
+
315
+ // Step 7: Build suppression mask
316
+ const suppressFrames = Math.max(0, Math.round(50 / 1000 / frameDurationSec));
317
+ const suppressMask = buildSuppressionMask(isActive, suppressFrames);
318
+
319
+ // Step 8: Convert to continuous activity level
320
+ const activityLevel = maskToActivityLevel(isActive, smoothFrames);
321
+
322
+ return {
323
+ times: spec.times,
324
+ activityLevel,
325
+ isActive,
326
+ suppressMask,
327
+ diagnostics: {
328
+ frameEnergy,
329
+ noiseFloor,
330
+ enterThreshold,
331
+ exitThreshold,
332
+ },
333
+ };
334
+ }
335
+
336
+ /**
337
+ * Compute Activity Signal from raw audio samples.
338
+ *
339
+ * Uses a simple RMS-based energy computation. This is useful when
340
+ * no spectrogram is available, but mel-based detection is preferred.
341
+ *
342
+ * @param samples - Mono audio samples
343
+ * @param sampleRate - Sample rate of the audio
344
+ * @param hopSize - Analysis hop size in samples
345
+ * @param windowSize - Analysis window size in samples
346
+ * @param config - Activity detection configuration
347
+ * @returns Complete activity signal with diagnostics
348
+ */
349
+ export function computeActivityFromAudio(
350
+ samples: Float32Array,
351
+ sampleRate: number,
352
+ hopSize: number,
353
+ windowSize: number,
354
+ config?: ActivityConfig
355
+ ): ActivitySignal {
356
+ const cfg = withActivityDefaults(config);
357
+
358
+ // Compute frame-based RMS energy
359
+ const nFrames = Math.max(0, Math.floor((samples.length - windowSize) / hopSize) + 1);
360
+ const times = new Float32Array(nFrames);
361
+ const frameEnergy = new Float32Array(nFrames);
362
+ const eps = 1e-12;
363
+
364
+ for (let frame = 0; frame < nFrames; frame++) {
365
+ const start = frame * hopSize;
366
+ const end = Math.min(start + windowSize, samples.length);
367
+
368
+ // RMS energy
369
+ let sumSq = 0;
370
+ for (let i = start; i < end; i++) {
371
+ const s = samples[i] ?? 0;
372
+ sumSq += s * s;
373
+ }
374
+ const rms = Math.sqrt(sumSq / (end - start));
375
+
376
+ times[frame] = (start + windowSize / 2) / sampleRate;
377
+ // Convert to log scale for threshold computation
378
+ frameEnergy[frame] = Math.log10(eps + rms);
379
+ }
380
+
381
+ // Estimate noise floor and thresholds
382
+ const noiseFloor = estimateNoiseFloor(frameEnergy, cfg.energyPercentile);
383
+ const enterThreshold = noiseFloor + cfg.enterMargin;
384
+ const exitThreshold = noiseFloor + cfg.exitMargin;
385
+
386
+ // Convert time parameters to frames
387
+ const frameDurationSec = hopSize / sampleRate;
388
+ const hangoverFrames = Math.max(0, Math.round(cfg.hangoverMs / 1000 / frameDurationSec));
389
+ const minActiveFrames = Math.max(0, Math.round(cfg.minActiveMs / 1000 / frameDurationSec));
390
+ const smoothFrames = Math.max(1, Math.round(cfg.smoothMs / 1000 / frameDurationSec)) | 1;
391
+
392
+ // Build activity mask
393
+ const isActive = buildActivityMask(frameEnergy, enterThreshold, exitThreshold, hangoverFrames);
394
+
395
+ if (minActiveFrames > 1) {
396
+ applyMinActiveDuration(isActive, minActiveFrames);
397
+ }
398
+
399
+ // Build suppression mask
400
+ const suppressFrames = Math.max(0, Math.round(50 / 1000 / frameDurationSec));
401
+ const suppressMask = buildSuppressionMask(isActive, suppressFrames);
402
+
403
+ // Convert to activity level
404
+ const activityLevel = maskToActivityLevel(isActive, smoothFrames);
405
+
406
+ return {
407
+ times,
408
+ activityLevel,
409
+ isActive,
410
+ suppressMask,
411
+ diagnostics: {
412
+ frameEnergy,
413
+ noiseFloor,
414
+ enterThreshold,
415
+ exitThreshold,
416
+ },
417
+ };
418
+ }
419
+
420
+ /**
421
+ * Apply activity gating to a 1D signal.
422
+ *
423
+ * Zeros out values where activity is low.
424
+ *
425
+ * @param values - Signal values to gate (modified in place)
426
+ * @param activity - Activity signal
427
+ * @param options - Gating options
428
+ */
429
+ export function applyActivityGating(
430
+ values: Float32Array,
431
+ activity: ActivitySignal,
432
+ options?: {
433
+ /** Use binary mask instead of continuous level. Default: true */
434
+ useBinaryMask?: boolean;
435
+ /** Also apply post-silence suppression. Default: true */
436
+ suppressPostSilence?: boolean;
437
+ /** Threshold for activity_level when not using binary mask. Default: 0.5 */
438
+ levelThreshold?: number;
439
+ }
440
+ ): void {
441
+ const useBinary = options?.useBinaryMask ?? true;
442
+ const suppressPostSilence = options?.suppressPostSilence ?? true;
443
+ const levelThreshold = options?.levelThreshold ?? 0.5;
444
+
445
+ const n = Math.min(values.length, activity.activityLevel.length);
446
+
447
+ for (let i = 0; i < n; i++) {
448
+ let shouldGate = false;
449
+
450
+ if (useBinary) {
451
+ shouldGate = (activity.isActive[i] ?? 0) === 0;
452
+ } else {
453
+ shouldGate = (activity.activityLevel[i] ?? 0) < levelThreshold;
454
+ }
455
+
456
+ if (suppressPostSilence && (activity.suppressMask[i] ?? 0) === 1) {
457
+ shouldGate = true;
458
+ }
459
+
460
+ if (shouldGate) {
461
+ values[i] = 0;
462
+ }
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Interpolate activity signal to a different time grid.
468
+ *
469
+ * Useful when you have activity computed at one resolution (e.g., mel frames)
470
+ * but need it at another resolution (e.g., pitch frames).
471
+ *
472
+ * @param activity - Source activity signal
473
+ * @param targetTimes - Target time grid
474
+ * @returns Activity signal interpolated to target times
475
+ */
476
+ export function interpolateActivity(
477
+ activity: ActivitySignal,
478
+ targetTimes: Float32Array
479
+ ): ActivitySignal {
480
+ const n = targetTimes.length;
481
+ const activityLevel = new Float32Array(n);
482
+ const isActive = new Uint8Array(n);
483
+ const suppressMask = new Uint8Array(n);
484
+ const frameEnergy = new Float32Array(n);
485
+
486
+ const srcTimes = activity.times;
487
+ const srcN = srcTimes.length;
488
+
489
+ if (srcN === 0) {
490
+ return {
491
+ times: targetTimes,
492
+ activityLevel,
493
+ isActive,
494
+ suppressMask,
495
+ diagnostics: {
496
+ frameEnergy,
497
+ noiseFloor: activity.diagnostics.noiseFloor,
498
+ enterThreshold: activity.diagnostics.enterThreshold,
499
+ exitThreshold: activity.diagnostics.exitThreshold,
500
+ },
501
+ };
502
+ }
503
+
504
+ // Nearest-neighbor interpolation for each target time
505
+ for (let i = 0; i < n; i++) {
506
+ const t = targetTimes[i] ?? 0;
507
+
508
+ // Binary search for nearest source frame
509
+ let lo = 0;
510
+ let hi = srcN - 1;
511
+ while (lo < hi) {
512
+ const mid = (lo + hi) >> 1;
513
+ if ((srcTimes[mid] ?? 0) < t) {
514
+ lo = mid + 1;
515
+ } else {
516
+ hi = mid;
517
+ }
518
+ }
519
+
520
+ // Check if lo or lo-1 is closer
521
+ let nearest = lo;
522
+ if (lo > 0 && Math.abs((srcTimes[lo - 1] ?? 0) - t) < Math.abs((srcTimes[lo] ?? 0) - t)) {
523
+ nearest = lo - 1;
524
+ }
525
+
526
+ activityLevel[i] = activity.activityLevel[nearest] ?? 0;
527
+ isActive[i] = activity.isActive[nearest] ?? 0;
528
+ suppressMask[i] = activity.suppressMask[nearest] ?? 0;
529
+ frameEnergy[i] = activity.diagnostics.frameEnergy[nearest] ?? 0;
530
+ }
531
+
532
+ return {
533
+ times: targetTimes,
534
+ activityLevel,
535
+ isActive,
536
+ suppressMask,
537
+ diagnostics: {
538
+ frameEnergy,
539
+ noiseFloor: activity.diagnostics.noiseFloor,
540
+ enterThreshold: activity.diagnostics.enterThreshold,
541
+ exitThreshold: activity.diagnostics.exitThreshold,
542
+ },
543
+ };
544
+ }