@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.
- package/dist/{chunk-OLIDGECY.js → chunk-CI7QGWP7.js} +44 -3
- package/dist/chunk-CI7QGWP7.js.map +1 -0
- package/dist/index.d.ts +475 -7
- package/dist/index.js +1495 -90
- package/dist/index.js.map +1 -1
- package/dist/{runMir-CWsxri61.d.ts → runMir-D4t7WsN0.d.ts} +1 -1
- package/dist/runner/runMir.d.ts +2 -2
- package/dist/runner/runMir.js +1 -1
- package/dist/runner/workerProtocol.d.ts +1 -1
- package/dist/{types-D6eBRofe.d.ts → types-ifqndzu7.d.ts} +86 -6
- package/package.json +1 -1
- package/src/dsp/bandCqt.ts +662 -0
- package/src/dsp/bandEvents.ts +351 -0
- package/src/dsp/bandMir.ts +69 -0
- package/src/dsp/bandProposal.ts +30 -2
- package/src/dsp/customSignalReduction.ts +841 -0
- package/src/dsp/frequencyBand.ts +46 -3
- package/src/dsp/peakPicking.ts +519 -0
- package/src/dsp/spectral.ts +54 -0
- package/src/index.ts +93 -1
- package/src/runner/runMir.ts +24 -1
- package/src/types.ts +97 -4
- package/dist/chunk-OLIDGECY.js.map +0 -1
|
@@ -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
|
+
}
|