@octoseq/mir 0.1.0-main.994cb4e → 0.1.0-main.9ea6d2e

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.
@@ -0,0 +1,841 @@
1
+ /**
2
+ * Custom Signal Reduction
3
+ *
4
+ * Provides algorithms for reducing 2D spectral data (mel spectrogram, HPSS,
5
+ * MFCC, etc.) to 1D signals with configurable bin ranges and reduction methods.
6
+ */
7
+
8
+ // ----------------------------
9
+ // Types
10
+ // ----------------------------
11
+
12
+ /**
13
+ * Input format for 2D time-aligned data.
14
+ * Shape: data[timeIndex][featureIndex]
15
+ */
16
+ export interface ReductionInput {
17
+ /** 2D data: data[timeIndex][featureIndex] */
18
+ data: Float32Array[];
19
+ /** Time in seconds for each frame */
20
+ times: Float32Array;
21
+ }
22
+
23
+ /**
24
+ * Reduction algorithm identifiers.
25
+ */
26
+ export type ReductionAlgorithmId =
27
+ | "mean"
28
+ | "max"
29
+ | "sum"
30
+ | "variance"
31
+ | "amplitude"
32
+ | "spectralFlux"
33
+ | "spectralCentroid"
34
+ | "onsetStrength";
35
+
36
+ /**
37
+ * Options for bin range selection.
38
+ */
39
+ export interface BinRangeOptions {
40
+ /** Low bin index (inclusive). Default: 0 */
41
+ lowBin?: number;
42
+ /** High bin index (exclusive). Default: all bins */
43
+ highBin?: number;
44
+ }
45
+
46
+ /**
47
+ * Options for onset strength algorithm.
48
+ */
49
+ export interface OnsetStrengthOptions {
50
+ /** Smoothing window in milliseconds. Default: 10 */
51
+ smoothMs?: number;
52
+ /** Whether to log-compress before differencing. Default: true */
53
+ useLog?: boolean;
54
+ /** Difference method. Default: "rectified" */
55
+ diffMethod?: "rectified" | "abs";
56
+ }
57
+
58
+ /**
59
+ * Options for spectral flux algorithm.
60
+ */
61
+ export interface SpectralFluxOptions {
62
+ /** Whether to normalize frames before computing flux. Default: true */
63
+ normalized?: boolean;
64
+ }
65
+
66
+ /**
67
+ * Combined options for reduction.
68
+ */
69
+ export interface ReductionOptions {
70
+ /** Bin range selection */
71
+ binRange?: BinRangeOptions;
72
+ /** Onset strength parameters */
73
+ onsetStrength?: OnsetStrengthOptions;
74
+ /** Spectral flux parameters */
75
+ spectralFlux?: SpectralFluxOptions;
76
+ }
77
+
78
+ /**
79
+ * Result of a reduction operation.
80
+ */
81
+ export interface ReductionResult {
82
+ /** Frame times in seconds */
83
+ times: Float32Array;
84
+ /** Reduced values per frame */
85
+ values: Float32Array;
86
+ /** Value range for normalization */
87
+ valueRange: { min: number; max: number };
88
+ }
89
+
90
+ // ----------------------------
91
+ // Internal Helpers
92
+ // ----------------------------
93
+
94
+ function logCompress(x: number): number {
95
+ return Math.log1p(Math.max(0, x));
96
+ }
97
+
98
+ function movingAverage(values: Float32Array, windowFrames: number): Float32Array {
99
+ if (windowFrames <= 1) return values;
100
+
101
+ const n = values.length;
102
+ const out = new Float32Array(n);
103
+ const half = Math.floor(windowFrames / 2);
104
+
105
+ // Prefix sums for O(n) moving average
106
+ const prefix = new Float64Array(n + 1);
107
+ prefix[0] = 0;
108
+ for (let i = 0; i < n; i++) {
109
+ prefix[i + 1] = (prefix[i] ?? 0) + (values[i] ?? 0);
110
+ }
111
+
112
+ for (let i = 0; i < n; i++) {
113
+ const start = Math.max(0, i - half);
114
+ const end = Math.min(n, i + half + 1);
115
+ const sum = (prefix[end] ?? 0) - (prefix[start] ?? 0);
116
+ const count = Math.max(1, end - start);
117
+ out[i] = sum / count;
118
+ }
119
+
120
+ return out;
121
+ }
122
+
123
+ function computeValueRange(values: Float32Array): { min: number; max: number } {
124
+ if (values.length === 0) {
125
+ return { min: 0, max: 0 };
126
+ }
127
+
128
+ let min = values[0] ?? 0;
129
+ let max = values[0] ?? 0;
130
+
131
+ for (let i = 1; i < values.length; i++) {
132
+ const v = values[i] ?? 0;
133
+ if (v < min) min = v;
134
+ if (v > max) max = v;
135
+ }
136
+
137
+ return { min, max };
138
+ }
139
+
140
+ function getBinRange(numBins: number, options?: BinRangeOptions): { low: number; high: number } {
141
+ const low = Math.max(0, options?.lowBin ?? 0);
142
+ const high = Math.min(numBins, options?.highBin ?? numBins);
143
+ return { low, high };
144
+ }
145
+
146
+ // ----------------------------
147
+ // Reduction Algorithms
148
+ // ----------------------------
149
+
150
+ /**
151
+ * Mean: Average value across bins per frame.
152
+ */
153
+ function reduceMean(input: ReductionInput, options?: ReductionOptions): ReductionResult {
154
+ const nFrames = input.data.length;
155
+ const values = new Float32Array(nFrames);
156
+
157
+ for (let t = 0; t < nFrames; t++) {
158
+ const frame = input.data[t];
159
+ if (!frame || frame.length === 0) {
160
+ values[t] = 0;
161
+ continue;
162
+ }
163
+
164
+ const { low, high } = getBinRange(frame.length, options?.binRange);
165
+ let sum = 0;
166
+ const count = high - low;
167
+
168
+ for (let k = low; k < high; k++) {
169
+ sum += frame[k] ?? 0;
170
+ }
171
+
172
+ values[t] = count > 0 ? sum / count : 0;
173
+ }
174
+
175
+ return {
176
+ times: input.times,
177
+ values,
178
+ valueRange: computeValueRange(values),
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Max: Maximum value across bins per frame.
184
+ */
185
+ function reduceMax(input: ReductionInput, options?: ReductionOptions): ReductionResult {
186
+ const nFrames = input.data.length;
187
+ const values = new Float32Array(nFrames);
188
+
189
+ for (let t = 0; t < nFrames; t++) {
190
+ const frame = input.data[t];
191
+ if (!frame || frame.length === 0) {
192
+ values[t] = 0;
193
+ continue;
194
+ }
195
+
196
+ const { low, high } = getBinRange(frame.length, options?.binRange);
197
+ let max = -Infinity;
198
+
199
+ for (let k = low; k < high; k++) {
200
+ const v = frame[k] ?? 0;
201
+ if (v > max) max = v;
202
+ }
203
+
204
+ values[t] = max === -Infinity ? 0 : max;
205
+ }
206
+
207
+ return {
208
+ times: input.times,
209
+ values,
210
+ valueRange: computeValueRange(values),
211
+ };
212
+ }
213
+
214
+ /**
215
+ * Sum: Sum of bin values per frame.
216
+ */
217
+ function reduceSum(input: ReductionInput, options?: ReductionOptions): ReductionResult {
218
+ const nFrames = input.data.length;
219
+ const values = new Float32Array(nFrames);
220
+
221
+ for (let t = 0; t < nFrames; t++) {
222
+ const frame = input.data[t];
223
+ if (!frame || frame.length === 0) {
224
+ values[t] = 0;
225
+ continue;
226
+ }
227
+
228
+ const { low, high } = getBinRange(frame.length, options?.binRange);
229
+ let sum = 0;
230
+
231
+ for (let k = low; k < high; k++) {
232
+ sum += frame[k] ?? 0;
233
+ }
234
+
235
+ values[t] = sum;
236
+ }
237
+
238
+ return {
239
+ times: input.times,
240
+ values,
241
+ valueRange: computeValueRange(values),
242
+ };
243
+ }
244
+
245
+ /**
246
+ * Variance: Variance of bin values per frame.
247
+ */
248
+ function reduceVariance(input: ReductionInput, options?: ReductionOptions): ReductionResult {
249
+ const nFrames = input.data.length;
250
+ const values = new Float32Array(nFrames);
251
+
252
+ for (let t = 0; t < nFrames; t++) {
253
+ const frame = input.data[t];
254
+ if (!frame || frame.length === 0) {
255
+ values[t] = 0;
256
+ continue;
257
+ }
258
+
259
+ const { low, high } = getBinRange(frame.length, options?.binRange);
260
+ const count = high - low;
261
+
262
+ if (count <= 1) {
263
+ values[t] = 0;
264
+ continue;
265
+ }
266
+
267
+ // Compute mean
268
+ let sum = 0;
269
+ for (let k = low; k < high; k++) {
270
+ sum += frame[k] ?? 0;
271
+ }
272
+ const mean = sum / count;
273
+
274
+ // Compute variance
275
+ let variance = 0;
276
+ for (let k = low; k < high; k++) {
277
+ const d = (frame[k] ?? 0) - mean;
278
+ variance += d * d;
279
+ }
280
+
281
+ values[t] = variance / count;
282
+ }
283
+
284
+ return {
285
+ times: input.times,
286
+ values,
287
+ valueRange: computeValueRange(values),
288
+ };
289
+ }
290
+
291
+ /**
292
+ * Amplitude: Sum of magnitudes (energy envelope).
293
+ * Same as sum but semantically represents amplitude envelope.
294
+ */
295
+ function reduceAmplitude(input: ReductionInput, options?: ReductionOptions): ReductionResult {
296
+ // Amplitude is conceptually the same as sum for magnitude data
297
+ return reduceSum(input, options);
298
+ }
299
+
300
+ /**
301
+ * Spectral Flux: L1 distance between consecutive (optionally normalized) frames.
302
+ */
303
+ function reduceSpectralFlux(input: ReductionInput, options?: ReductionOptions): ReductionResult {
304
+ const nFrames = input.data.length;
305
+ const values = new Float32Array(nFrames);
306
+ const normalized = options?.spectralFlux?.normalized ?? true;
307
+
308
+ if (nFrames === 0) {
309
+ return {
310
+ times: input.times,
311
+ values,
312
+ valueRange: { min: 0, max: 0 },
313
+ };
314
+ }
315
+
316
+ // First frame has no previous
317
+ values[0] = 0;
318
+ let prevNorm: Float32Array | null = null;
319
+
320
+ for (let t = 0; t < nFrames; t++) {
321
+ const frame = input.data[t];
322
+ if (!frame || frame.length === 0) {
323
+ values[t] = 0;
324
+ prevNorm = null;
325
+ continue;
326
+ }
327
+
328
+ const { low, high } = getBinRange(frame.length, options?.binRange);
329
+
330
+ // Extract and optionally normalize
331
+ let curNorm: Float32Array;
332
+
333
+ if (normalized) {
334
+ let sum = 0;
335
+ for (let k = low; k < high; k++) {
336
+ sum += frame[k] ?? 0;
337
+ }
338
+
339
+ if (sum <= 0) {
340
+ values[t] = 0;
341
+ prevNorm = null;
342
+ continue;
343
+ }
344
+
345
+ const inv = 1 / sum;
346
+ curNorm = new Float32Array(high - low);
347
+ for (let k = low; k < high; k++) {
348
+ curNorm[k - low] = (frame[k] ?? 0) * inv;
349
+ }
350
+ } else {
351
+ curNorm = new Float32Array(high - low);
352
+ for (let k = low; k < high; k++) {
353
+ curNorm[k - low] = frame[k] ?? 0;
354
+ }
355
+ }
356
+
357
+ if (!prevNorm || prevNorm.length !== curNorm.length) {
358
+ values[t] = 0;
359
+ prevNorm = curNorm;
360
+ continue;
361
+ }
362
+
363
+ // L1 distance
364
+ let flux = 0;
365
+ for (let k = 0; k < curNorm.length; k++) {
366
+ flux += Math.abs((curNorm[k] ?? 0) - (prevNorm[k] ?? 0));
367
+ }
368
+
369
+ values[t] = flux;
370
+ prevNorm = curNorm;
371
+ }
372
+
373
+ return {
374
+ times: input.times,
375
+ values,
376
+ valueRange: computeValueRange(values),
377
+ };
378
+ }
379
+
380
+ /**
381
+ * Spectral Centroid: Weighted center of mass in bin space.
382
+ * Returns bin index (not Hz - caller can convert if needed).
383
+ */
384
+ function reduceSpectralCentroid(input: ReductionInput, options?: ReductionOptions): ReductionResult {
385
+ const nFrames = input.data.length;
386
+ const values = new Float32Array(nFrames);
387
+
388
+ for (let t = 0; t < nFrames; t++) {
389
+ const frame = input.data[t];
390
+ if (!frame || frame.length === 0) {
391
+ values[t] = 0;
392
+ continue;
393
+ }
394
+
395
+ const { low, high } = getBinRange(frame.length, options?.binRange);
396
+
397
+ let num = 0;
398
+ let den = 0;
399
+
400
+ for (let k = low; k < high; k++) {
401
+ const m = frame[k] ?? 0;
402
+ if (m > 0) {
403
+ num += k * m;
404
+ den += m;
405
+ }
406
+ }
407
+
408
+ values[t] = den > 0 ? num / den : 0;
409
+ }
410
+
411
+ return {
412
+ times: input.times,
413
+ values,
414
+ valueRange: computeValueRange(values),
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Onset Strength: Temporal derivative with optional log compression and smoothing.
420
+ */
421
+ function reduceOnsetStrength(input: ReductionInput, options?: ReductionOptions): ReductionResult {
422
+ const nFrames = input.data.length;
423
+ const values = new Float32Array(nFrames);
424
+
425
+ const useLog = options?.onsetStrength?.useLog ?? true;
426
+ const smoothMs = options?.onsetStrength?.smoothMs ?? 10;
427
+ const diffMethod = options?.onsetStrength?.diffMethod ?? "rectified";
428
+
429
+ if (nFrames === 0) {
430
+ return {
431
+ times: input.times,
432
+ values,
433
+ valueRange: { min: 0, max: 0 },
434
+ };
435
+ }
436
+
437
+ // First frame has no previous
438
+ values[0] = 0;
439
+
440
+ for (let t = 1; t < nFrames; t++) {
441
+ const cur = input.data[t];
442
+ const prev = input.data[t - 1];
443
+
444
+ if (!cur || !prev || cur.length === 0 || prev.length === 0) {
445
+ values[t] = 0;
446
+ continue;
447
+ }
448
+
449
+ const { low, high } = getBinRange(cur.length, options?.binRange);
450
+ let sum = 0;
451
+ let binsWithData = 0;
452
+
453
+ for (let k = low; k < high; k++) {
454
+ let a = cur[k] ?? 0;
455
+ let b = prev[k] ?? 0;
456
+
457
+ if (a > 0 || b > 0) {
458
+ binsWithData++;
459
+
460
+ if (useLog) {
461
+ a = logCompress(a);
462
+ b = logCompress(b);
463
+ }
464
+
465
+ const d = a - b;
466
+ sum += diffMethod === "abs" ? Math.abs(d) : Math.max(0, d);
467
+ }
468
+ }
469
+
470
+ values[t] = binsWithData > 0 ? sum / binsWithData : 0;
471
+ }
472
+
473
+ // Apply smoothing
474
+ if (smoothMs > 0 && nFrames >= 2) {
475
+ const dt = (input.times[1] ?? 0) - (input.times[0] ?? 0);
476
+ if (dt > 0) {
477
+ const windowFrames = Math.max(1, Math.round((smoothMs / 1000) / dt));
478
+ const smoothed = movingAverage(values, windowFrames | 1);
479
+ return {
480
+ times: input.times,
481
+ values: smoothed,
482
+ valueRange: computeValueRange(smoothed),
483
+ };
484
+ }
485
+ }
486
+
487
+ return {
488
+ times: input.times,
489
+ values,
490
+ valueRange: computeValueRange(values),
491
+ };
492
+ }
493
+
494
+ // ----------------------------
495
+ // Main API
496
+ // ----------------------------
497
+
498
+ /**
499
+ * Reduce 2D time-aligned data to a 1D signal using the specified algorithm.
500
+ *
501
+ * @param input - 2D input data (frames x bins)
502
+ * @param algorithm - Reduction algorithm to use
503
+ * @param options - Algorithm options including bin range selection
504
+ * @returns Reduction result with times, values, and value range
505
+ */
506
+ export function reduce2DToSignal(
507
+ input: ReductionInput,
508
+ algorithm: ReductionAlgorithmId,
509
+ options?: ReductionOptions
510
+ ): ReductionResult {
511
+ switch (algorithm) {
512
+ case "mean":
513
+ return reduceMean(input, options);
514
+ case "max":
515
+ return reduceMax(input, options);
516
+ case "sum":
517
+ return reduceSum(input, options);
518
+ case "variance":
519
+ return reduceVariance(input, options);
520
+ case "amplitude":
521
+ return reduceAmplitude(input, options);
522
+ case "spectralFlux":
523
+ return reduceSpectralFlux(input, options);
524
+ case "spectralCentroid":
525
+ return reduceSpectralCentroid(input, options);
526
+ case "onsetStrength":
527
+ return reduceOnsetStrength(input, options);
528
+ default:
529
+ // Exhaustive check
530
+ const _exhaustive: never = algorithm;
531
+ throw new Error(`Unknown reduction algorithm: ${_exhaustive}`);
532
+ }
533
+ }
534
+
535
+ /**
536
+ * Get human-readable label for a reduction algorithm.
537
+ */
538
+ export function getReductionAlgorithmLabel(algorithm: ReductionAlgorithmId): string {
539
+ switch (algorithm) {
540
+ case "mean":
541
+ return "Mean";
542
+ case "max":
543
+ return "Maximum";
544
+ case "sum":
545
+ return "Sum";
546
+ case "variance":
547
+ return "Variance";
548
+ case "amplitude":
549
+ return "Amplitude Envelope";
550
+ case "spectralFlux":
551
+ return "Spectral Flux";
552
+ case "spectralCentroid":
553
+ return "Spectral Centroid";
554
+ case "onsetStrength":
555
+ return "Onset Strength";
556
+ default:
557
+ return String(algorithm);
558
+ }
559
+ }
560
+
561
+ /**
562
+ * Get description for a reduction algorithm.
563
+ */
564
+ export function getReductionAlgorithmDescription(algorithm: ReductionAlgorithmId): string {
565
+ switch (algorithm) {
566
+ case "mean":
567
+ return "Average value across all bins per frame";
568
+ case "max":
569
+ return "Maximum value across all bins per frame";
570
+ case "sum":
571
+ return "Sum of all bin values per frame";
572
+ case "variance":
573
+ return "Variance of bin values per frame";
574
+ case "amplitude":
575
+ return "Sum of magnitudes (energy envelope)";
576
+ case "spectralFlux":
577
+ return "Change between consecutive frames";
578
+ case "spectralCentroid":
579
+ return "Weighted center frequency";
580
+ case "onsetStrength":
581
+ return "Temporal derivative for onset detection";
582
+ default:
583
+ return "";
584
+ }
585
+ }
586
+
587
+ // ----------------------------
588
+ // Polarity Types
589
+ // ----------------------------
590
+
591
+ /**
592
+ * Polarity interpretation mode.
593
+ * - "signed": Preserve direction (signal can be positive or negative)
594
+ * - "magnitude": Activity level only (always positive, uses absolute value)
595
+ */
596
+ export type PolarityMode = "signed" | "magnitude";
597
+
598
+ /**
599
+ * Apply polarity interpretation to a signal.
600
+ * Called after reduction, before stabilization.
601
+ *
602
+ * @param values - Input signal values
603
+ * @param mode - Polarity mode to apply
604
+ * @returns Transformed signal values
605
+ */
606
+ export function applyPolarity(values: Float32Array, mode: PolarityMode): Float32Array {
607
+ if (mode === "signed") {
608
+ // No transformation needed
609
+ return values;
610
+ }
611
+
612
+ // Magnitude mode: apply absolute value
613
+ const result = new Float32Array(values.length);
614
+ for (let i = 0; i < values.length; i++) {
615
+ result[i] = Math.abs(values[i] ?? 0);
616
+ }
617
+ return result;
618
+ }
619
+
620
+ // ----------------------------
621
+ // Stabilization Types
622
+ // ----------------------------
623
+
624
+ /**
625
+ * Stabilization mode presets.
626
+ */
627
+ export type StabilizationMode = "none" | "light" | "medium" | "heavy";
628
+
629
+ /**
630
+ * Envelope mode for signal shaping.
631
+ */
632
+ export type EnvelopeMode = "raw" | "attackRelease";
633
+
634
+ /**
635
+ * Options for signal stabilization.
636
+ */
637
+ export interface StabilizationOptions {
638
+ /** Smoothing intensity preset. */
639
+ mode: StabilizationMode;
640
+ /** Envelope shaping mode. */
641
+ envelopeMode: EnvelopeMode;
642
+ /** Attack time in seconds (only for attackRelease mode). */
643
+ attackTimeSec?: number;
644
+ /** Release time in seconds (only for attackRelease mode). */
645
+ releaseTimeSec?: number;
646
+ }
647
+
648
+ // ----------------------------
649
+ // Stabilization Implementation
650
+ // ----------------------------
651
+
652
+ /**
653
+ * Get smoothing window size for a stabilization mode.
654
+ * Returns window size in number of frames.
655
+ */
656
+ function getStabilizationWindowFrames(mode: StabilizationMode, frameTime: number): number {
657
+ // Map mode to smoothing time in seconds
658
+ const smoothingTimes: Record<StabilizationMode, number> = {
659
+ none: 0,
660
+ light: 0.01, // 10ms
661
+ medium: 0.03, // 30ms
662
+ heavy: 0.1, // 100ms
663
+ };
664
+
665
+ const smoothMs = smoothingTimes[mode] * 1000;
666
+ if (smoothMs <= 0 || frameTime <= 0) return 1;
667
+
668
+ return Math.max(1, Math.round((smoothMs / 1000) / frameTime)) | 1; // Ensure odd
669
+ }
670
+
671
+ /**
672
+ * Apply attack/release envelope following.
673
+ * Attack: how fast the signal can rise
674
+ * Release: how fast the signal can fall
675
+ */
676
+ function applyAttackRelease(
677
+ values: Float32Array,
678
+ times: Float32Array,
679
+ attackTimeSec: number,
680
+ releaseTimeSec: number
681
+ ): Float32Array {
682
+ const n = values.length;
683
+ if (n === 0) return values;
684
+
685
+ const out = new Float32Array(n);
686
+ out[0] = values[0] ?? 0;
687
+
688
+ for (let i = 1; i < n; i++) {
689
+ const dt = (times[i] ?? 0) - (times[i - 1] ?? 0);
690
+ if (dt <= 0) {
691
+ out[i] = values[i] ?? 0;
692
+ continue;
693
+ }
694
+
695
+ const current = values[i] ?? 0;
696
+ const prev = out[i - 1] ?? 0;
697
+
698
+ if (current > prev) {
699
+ // Rising - apply attack time constant
700
+ if (attackTimeSec > 0) {
701
+ const alpha = 1 - Math.exp(-dt / attackTimeSec);
702
+ out[i] = prev + alpha * (current - prev);
703
+ } else {
704
+ out[i] = current;
705
+ }
706
+ } else {
707
+ // Falling - apply release time constant
708
+ if (releaseTimeSec > 0) {
709
+ const alpha = 1 - Math.exp(-dt / releaseTimeSec);
710
+ out[i] = prev + alpha * (current - prev);
711
+ } else {
712
+ out[i] = current;
713
+ }
714
+ }
715
+ }
716
+
717
+ return out;
718
+ }
719
+
720
+ /**
721
+ * Apply stabilization to a signal.
722
+ *
723
+ * @param values - Input signal values
724
+ * @param times - Frame times in seconds
725
+ * @param options - Stabilization options
726
+ * @returns Stabilized signal values
727
+ */
728
+ export function stabilizeSignal(
729
+ values: Float32Array,
730
+ times: Float32Array,
731
+ options: StabilizationOptions
732
+ ): Float32Array {
733
+ if (values.length === 0) return values;
734
+
735
+ let result = values;
736
+
737
+ // Step 1: Apply smoothing based on mode
738
+ if (options.mode !== "none" && times.length >= 2) {
739
+ const dt = (times[1] ?? 0) - (times[0] ?? 0);
740
+ if (dt > 0) {
741
+ const windowFrames = getStabilizationWindowFrames(options.mode, dt);
742
+ if (windowFrames > 1) {
743
+ result = movingAverage(result, windowFrames);
744
+ }
745
+ }
746
+ }
747
+
748
+ // Step 2: Apply envelope shaping
749
+ if (options.envelopeMode === "attackRelease") {
750
+ const attackSec = options.attackTimeSec ?? 0.01;
751
+ const releaseSec = options.releaseTimeSec ?? 0.1;
752
+ result = applyAttackRelease(result, times, attackSec, releaseSec);
753
+ }
754
+
755
+ return result;
756
+ }
757
+
758
+ // ----------------------------
759
+ // Statistics Helpers
760
+ // ----------------------------
761
+
762
+ /**
763
+ * Compute percentile values from a signal.
764
+ *
765
+ * @param values - Signal values
766
+ * @param percentiles - Array of percentiles to compute (0-100)
767
+ * @returns Object mapping percentile to value
768
+ */
769
+ export function computePercentiles(
770
+ values: Float32Array,
771
+ percentiles: number[]
772
+ ): Record<number, number> {
773
+ if (values.length === 0) {
774
+ return Object.fromEntries(percentiles.map((p) => [p, 0]));
775
+ }
776
+
777
+ // Sort a copy
778
+ const sorted = Float32Array.from(values).sort((a, b) => a - b);
779
+ const n = sorted.length;
780
+
781
+ const result: Record<number, number> = {};
782
+
783
+ for (const p of percentiles) {
784
+ const clamped = Math.max(0, Math.min(100, p));
785
+ const index = (clamped / 100) * (n - 1);
786
+ const lower = Math.floor(index);
787
+ const upper = Math.min(lower + 1, n - 1);
788
+ const frac = index - lower;
789
+
790
+ // Linear interpolation
791
+ result[p] = (sorted[lower] ?? 0) * (1 - frac) + (sorted[upper] ?? 0) * frac;
792
+ }
793
+
794
+ return result;
795
+ }
796
+
797
+ /**
798
+ * Compute local (viewport) statistics for a signal.
799
+ *
800
+ * @param values - Signal values
801
+ * @param times - Frame times in seconds
802
+ * @param startTime - Viewport start time
803
+ * @param endTime - Viewport end time
804
+ * @returns Statistics within the viewport
805
+ */
806
+ export function computeLocalStats(
807
+ values: Float32Array,
808
+ times: Float32Array,
809
+ startTime: number,
810
+ endTime: number
811
+ ): { min: number; max: number; p5: number; p95: number } {
812
+ // Find frames within viewport
813
+ const indices: number[] = [];
814
+ for (let i = 0; i < times.length; i++) {
815
+ const t = times[i] ?? 0;
816
+ if (t >= startTime && t <= endTime) {
817
+ indices.push(i);
818
+ }
819
+ }
820
+
821
+ if (indices.length === 0) {
822
+ return { min: 0, max: 0, p5: 0, p95: 0 };
823
+ }
824
+
825
+ // Extract values in viewport
826
+ const viewportValues = new Float32Array(indices.length);
827
+ for (let i = 0; i < indices.length; i++) {
828
+ viewportValues[i] = values[indices[i] ?? 0] ?? 0;
829
+ }
830
+
831
+ // Compute stats
832
+ const range = computeValueRange(viewportValues);
833
+ const percentiles = computePercentiles(viewportValues, [5, 95]);
834
+
835
+ return {
836
+ min: range.min,
837
+ max: range.max,
838
+ p5: percentiles[5] ?? 0,
839
+ p95: percentiles[95] ?? 0,
840
+ };
841
+ }