@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.
- package/dist/chunk-HF3QHCRK.js +3234 -0
- package/dist/chunk-HF3QHCRK.js.map +1 -0
- package/dist/index.d.ts +2122 -47
- package/dist/index.js +3524 -39
- package/dist/index.js.map +1 -1
- package/dist/runMir-CnJQbBr8.d.ts +187 -0
- package/dist/runner/runMir.d.ts +2 -2
- package/dist/runner/runMir.js +1 -1
- package/dist/runner/workerProtocol.d.ts +8 -1
- package/dist/runner/workerProtocol.js.map +1 -1
- package/dist/types-DqH4umN8.d.ts +761 -0
- package/package.json +2 -2
- package/src/dsp/activity.ts +544 -0
- package/src/dsp/bandCqt.ts +662 -0
- package/src/dsp/bandEvents.ts +351 -0
- package/src/dsp/bandMask.ts +225 -0
- package/src/dsp/bandMir.ts +524 -0
- package/src/dsp/bandProposal.ts +552 -0
- package/src/dsp/beatCandidates.ts +299 -0
- package/src/dsp/cqt.ts +386 -0
- package/src/dsp/cqtSignals.ts +462 -0
- package/src/dsp/customSignalReduction.ts +841 -0
- package/src/dsp/eventToSignal.ts +531 -0
- package/src/dsp/frequencyBand.ts +956 -0
- package/src/dsp/mel.ts +56 -3
- package/src/dsp/musicalTime.ts +240 -0
- package/src/dsp/onset.ts +296 -30
- package/src/dsp/peakPicking.ts +519 -0
- package/src/dsp/phaseAlignment.ts +153 -0
- package/src/dsp/pitch.ts +289 -0
- package/src/dsp/resample.ts +44 -0
- package/src/dsp/signalTransforms.ts +660 -0
- package/src/dsp/silenceGating.ts +511 -0
- package/src/dsp/spectral.ts +124 -4
- package/src/dsp/tempoHypotheses.ts +395 -0
- package/src/gpu/bufferPool.ts +266 -0
- package/src/gpu/context.ts +30 -6
- package/src/gpu/helpers.ts +85 -0
- package/src/gpu/melProject.ts +83 -0
- package/src/index.ts +366 -5
- package/src/runner/runMir.ts +234 -2
- package/src/runner/workerProtocol.ts +9 -1
- package/src/types.ts +768 -3
- package/dist/chunk-DUWYCAVG.js +0 -1525
- package/dist/chunk-DUWYCAVG.js.map +0 -1
- package/dist/runMir-CSIBwNZ3.d.ts +0 -84
- package/dist/types-BE3py4fZ.d.ts +0 -83
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peak Picking Algorithm
|
|
3
|
+
*
|
|
4
|
+
* Detects local maxima in a 1D signal that exceed a threshold.
|
|
5
|
+
* Used to create event streams from continuous signals.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PeakPickingParams {
|
|
9
|
+
/** Minimum normalized value (0-1) for a peak to be considered. Default: 0.3 */
|
|
10
|
+
threshold: number;
|
|
11
|
+
/** Minimum time between peaks in seconds. Default: 0.1 */
|
|
12
|
+
minDistance: number;
|
|
13
|
+
/** Number of samples to look back for local max comparison. Default: 2 */
|
|
14
|
+
preMax?: number;
|
|
15
|
+
/** Number of samples to look forward for local max comparison. Default: 2 */
|
|
16
|
+
postMax?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PeakPickingResult {
|
|
20
|
+
/** Times of detected peaks (seconds) */
|
|
21
|
+
times: Float32Array;
|
|
22
|
+
/** Normalized strength of each peak (0-1, based on normalized value) */
|
|
23
|
+
strengths: Float32Array;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default peak picking parameters
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_PEAK_PICKING_PARAMS: Required<PeakPickingParams> = {
|
|
30
|
+
threshold: 0.3,
|
|
31
|
+
minDistance: 0.1,
|
|
32
|
+
preMax: 2,
|
|
33
|
+
postMax: 2,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pick peaks from a continuous signal.
|
|
38
|
+
*
|
|
39
|
+
* @param times - Time values in seconds (Float32Array)
|
|
40
|
+
* @param values - Signal values (Float32Array)
|
|
41
|
+
* @param params - Peak picking parameters
|
|
42
|
+
* @returns Detected peaks with times and strengths
|
|
43
|
+
*/
|
|
44
|
+
export function pickPeaks(
|
|
45
|
+
times: Float32Array,
|
|
46
|
+
values: Float32Array,
|
|
47
|
+
params: Partial<PeakPickingParams> = {}
|
|
48
|
+
): PeakPickingResult {
|
|
49
|
+
const {
|
|
50
|
+
threshold,
|
|
51
|
+
minDistance,
|
|
52
|
+
preMax,
|
|
53
|
+
postMax,
|
|
54
|
+
} = { ...DEFAULT_PEAK_PICKING_PARAMS, ...params };
|
|
55
|
+
|
|
56
|
+
if (times.length === 0 || values.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
times: new Float32Array(0),
|
|
59
|
+
strengths: new Float32Array(0),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Find min/max for normalization
|
|
64
|
+
let minVal = values[0]!;
|
|
65
|
+
let maxVal = values[0]!;
|
|
66
|
+
for (let i = 1; i < values.length; i++) {
|
|
67
|
+
const v = values[i]!;
|
|
68
|
+
if (v < minVal) minVal = v;
|
|
69
|
+
if (v > maxVal) maxVal = v;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const range = maxVal - minVal;
|
|
73
|
+
if (range <= 0) {
|
|
74
|
+
// Constant signal - no peaks
|
|
75
|
+
return {
|
|
76
|
+
times: new Float32Array(0),
|
|
77
|
+
strengths: new Float32Array(0),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Normalize function
|
|
82
|
+
const normalize = (v: number) => (v - minVal) / range;
|
|
83
|
+
|
|
84
|
+
// Find peaks
|
|
85
|
+
const peakIndices: number[] = [];
|
|
86
|
+
const peakStrengths: number[] = [];
|
|
87
|
+
|
|
88
|
+
for (let i = preMax; i < values.length - postMax; i++) {
|
|
89
|
+
const currentVal = values[i]!;
|
|
90
|
+
const normalizedVal = normalize(currentVal);
|
|
91
|
+
|
|
92
|
+
// Skip if below threshold
|
|
93
|
+
if (normalizedVal < threshold) continue;
|
|
94
|
+
|
|
95
|
+
// Check if local maximum
|
|
96
|
+
let isMax = true;
|
|
97
|
+
|
|
98
|
+
// Check pre-samples
|
|
99
|
+
for (let j = 1; j <= preMax; j++) {
|
|
100
|
+
if (values[i - j]! >= currentVal) {
|
|
101
|
+
isMax = false;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check post-samples
|
|
107
|
+
if (isMax) {
|
|
108
|
+
for (let j = 1; j <= postMax; j++) {
|
|
109
|
+
if (values[i + j]! > currentVal) {
|
|
110
|
+
isMax = false;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isMax) {
|
|
117
|
+
peakIndices.push(i);
|
|
118
|
+
peakStrengths.push(normalizedVal);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Apply minimum distance constraint (keep higher peaks)
|
|
123
|
+
const filteredIndices: number[] = [];
|
|
124
|
+
const filteredStrengths: number[] = [];
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < peakIndices.length; i++) {
|
|
127
|
+
const idx = peakIndices[i]!;
|
|
128
|
+
const time = times[idx]!;
|
|
129
|
+
const strength = peakStrengths[i]!;
|
|
130
|
+
|
|
131
|
+
// Check if there's a higher peak within minDistance
|
|
132
|
+
let shouldKeep = true;
|
|
133
|
+
|
|
134
|
+
for (let j = 0; j < filteredIndices.length; j++) {
|
|
135
|
+
const prevIdx = filteredIndices[j]!;
|
|
136
|
+
const prevTime = times[prevIdx]!;
|
|
137
|
+
const prevStrength = filteredStrengths[j]!;
|
|
138
|
+
|
|
139
|
+
if (Math.abs(time - prevTime) < minDistance) {
|
|
140
|
+
if (strength > prevStrength) {
|
|
141
|
+
// Replace previous peak
|
|
142
|
+
filteredIndices[j] = idx;
|
|
143
|
+
filteredStrengths[j] = strength;
|
|
144
|
+
}
|
|
145
|
+
shouldKeep = false;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (shouldKeep) {
|
|
151
|
+
filteredIndices.push(idx);
|
|
152
|
+
filteredStrengths.push(strength);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Convert to output format
|
|
157
|
+
const resultTimes = new Float32Array(filteredIndices.length);
|
|
158
|
+
const resultStrengths = new Float32Array(filteredStrengths.length);
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < filteredIndices.length; i++) {
|
|
161
|
+
resultTimes[i] = times[filteredIndices[i]!]!;
|
|
162
|
+
resultStrengths[i] = filteredStrengths[i]!;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
times: resultTimes,
|
|
167
|
+
strengths: resultStrengths,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Result from adaptive peak picking, including the threshold curve for visualization.
|
|
173
|
+
*/
|
|
174
|
+
export interface AdaptivePeakPickingResult extends PeakPickingResult {
|
|
175
|
+
/** The adaptive threshold curve (normalized 0-1) for visualization */
|
|
176
|
+
thresholdCurve: Float32Array;
|
|
177
|
+
/** Time values corresponding to threshold curve samples */
|
|
178
|
+
thresholdTimes: Float32Array;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Compute the adaptive threshold curve for visualization.
|
|
183
|
+
* This is the normalized threshold that would be used by pickPeaksAdaptive.
|
|
184
|
+
*
|
|
185
|
+
* @param values - Signal values (Float32Array)
|
|
186
|
+
* @param windowSize - Window size for local statistics (in samples)
|
|
187
|
+
* @param thresholdMultiplier - Multiplier for local std (default 1.5)
|
|
188
|
+
* @returns Normalized threshold curve (0-1 range)
|
|
189
|
+
*/
|
|
190
|
+
export function computeAdaptiveThreshold(
|
|
191
|
+
values: Float32Array,
|
|
192
|
+
windowSize: number = 20,
|
|
193
|
+
thresholdMultiplier: number = 1.5
|
|
194
|
+
): Float32Array {
|
|
195
|
+
if (values.length === 0) {
|
|
196
|
+
return new Float32Array(0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Find min/max for normalization
|
|
200
|
+
let minVal = values[0]!;
|
|
201
|
+
let maxVal = values[0]!;
|
|
202
|
+
for (let i = 1; i < values.length; i++) {
|
|
203
|
+
const v = values[i]!;
|
|
204
|
+
if (v < minVal) minVal = v;
|
|
205
|
+
if (v > maxVal) maxVal = v;
|
|
206
|
+
}
|
|
207
|
+
const range = maxVal - minVal;
|
|
208
|
+
if (range <= 0) {
|
|
209
|
+
return new Float32Array(values.length).fill(0.5);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Compute adaptive threshold
|
|
213
|
+
const halfWindow = Math.floor(windowSize / 2);
|
|
214
|
+
const thresholdCurve = new Float32Array(values.length);
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < values.length; i++) {
|
|
217
|
+
const start = Math.max(0, i - halfWindow);
|
|
218
|
+
const end = Math.min(values.length, i + halfWindow + 1);
|
|
219
|
+
const windowLen = end - start;
|
|
220
|
+
|
|
221
|
+
// Compute local mean
|
|
222
|
+
let sum = 0;
|
|
223
|
+
for (let j = start; j < end; j++) {
|
|
224
|
+
sum += values[j]!;
|
|
225
|
+
}
|
|
226
|
+
const mean = sum / windowLen;
|
|
227
|
+
|
|
228
|
+
// Compute local std
|
|
229
|
+
let sumSq = 0;
|
|
230
|
+
for (let j = start; j < end; j++) {
|
|
231
|
+
const diff = values[j]! - mean;
|
|
232
|
+
sumSq += diff * diff;
|
|
233
|
+
}
|
|
234
|
+
const std = Math.sqrt(sumSq / windowLen);
|
|
235
|
+
|
|
236
|
+
// Adaptive threshold in original scale
|
|
237
|
+
const adaptiveThresh = mean + thresholdMultiplier * std;
|
|
238
|
+
|
|
239
|
+
// Normalize to 0-1 range
|
|
240
|
+
thresholdCurve[i] = Math.max(0, Math.min(1, (adaptiveThresh - minVal) / range));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return thresholdCurve;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Pick peaks with adaptive threshold based on local statistics.
|
|
248
|
+
*
|
|
249
|
+
* @param times - Time values in seconds
|
|
250
|
+
* @param values - Signal values
|
|
251
|
+
* @param windowSize - Window size for local statistics (in samples)
|
|
252
|
+
* @param params - Base peak picking parameters (threshold used as multiplier of local std)
|
|
253
|
+
* @param includeThresholdCurve - If true, returns the threshold curve for visualization
|
|
254
|
+
*/
|
|
255
|
+
export function pickPeaksAdaptive(
|
|
256
|
+
times: Float32Array,
|
|
257
|
+
values: Float32Array,
|
|
258
|
+
windowSize: number = 20,
|
|
259
|
+
params: Partial<PeakPickingParams> = {},
|
|
260
|
+
includeThresholdCurve: boolean = false
|
|
261
|
+
): PeakPickingResult | AdaptivePeakPickingResult {
|
|
262
|
+
const {
|
|
263
|
+
threshold,
|
|
264
|
+
minDistance,
|
|
265
|
+
preMax,
|
|
266
|
+
postMax,
|
|
267
|
+
} = { ...DEFAULT_PEAK_PICKING_PARAMS, threshold: 1.5, ...params };
|
|
268
|
+
|
|
269
|
+
if (times.length === 0 || values.length === 0) {
|
|
270
|
+
return {
|
|
271
|
+
times: new Float32Array(0),
|
|
272
|
+
strengths: new Float32Array(0),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Compute adaptive threshold using local mean + threshold * local std
|
|
277
|
+
const adaptiveThreshold = new Float32Array(values.length);
|
|
278
|
+
const halfWindow = Math.floor(windowSize / 2);
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < values.length; i++) {
|
|
281
|
+
const start = Math.max(0, i - halfWindow);
|
|
282
|
+
const end = Math.min(values.length, i + halfWindow + 1);
|
|
283
|
+
const windowLen = end - start;
|
|
284
|
+
|
|
285
|
+
// Compute local mean
|
|
286
|
+
let sum = 0;
|
|
287
|
+
for (let j = start; j < end; j++) {
|
|
288
|
+
sum += values[j]!;
|
|
289
|
+
}
|
|
290
|
+
const mean = sum / windowLen;
|
|
291
|
+
|
|
292
|
+
// Compute local std
|
|
293
|
+
let sumSq = 0;
|
|
294
|
+
for (let j = start; j < end; j++) {
|
|
295
|
+
const diff = values[j]! - mean;
|
|
296
|
+
sumSq += diff * diff;
|
|
297
|
+
}
|
|
298
|
+
const std = Math.sqrt(sumSq / windowLen);
|
|
299
|
+
|
|
300
|
+
adaptiveThreshold[i] = mean + threshold * std;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Find min/max for strength normalization
|
|
304
|
+
let minVal = values[0]!;
|
|
305
|
+
let maxVal = values[0]!;
|
|
306
|
+
for (let i = 1; i < values.length; i++) {
|
|
307
|
+
const v = values[i]!;
|
|
308
|
+
if (v < minVal) minVal = v;
|
|
309
|
+
if (v > maxVal) maxVal = v;
|
|
310
|
+
}
|
|
311
|
+
const range = maxVal - minVal;
|
|
312
|
+
const normalize = range > 0 ? (v: number) => (v - minVal) / range : () => 0.5;
|
|
313
|
+
|
|
314
|
+
// Find peaks
|
|
315
|
+
const peakIndices: number[] = [];
|
|
316
|
+
const peakStrengths: number[] = [];
|
|
317
|
+
|
|
318
|
+
for (let i = preMax; i < values.length - postMax; i++) {
|
|
319
|
+
const currentVal = values[i]!;
|
|
320
|
+
|
|
321
|
+
// Skip if below adaptive threshold
|
|
322
|
+
if (currentVal < adaptiveThreshold[i]!) continue;
|
|
323
|
+
|
|
324
|
+
// Check if local maximum
|
|
325
|
+
let isMax = true;
|
|
326
|
+
|
|
327
|
+
for (let j = 1; j <= preMax; j++) {
|
|
328
|
+
if (values[i - j]! >= currentVal) {
|
|
329
|
+
isMax = false;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (isMax) {
|
|
335
|
+
for (let j = 1; j <= postMax; j++) {
|
|
336
|
+
if (values[i + j]! > currentVal) {
|
|
337
|
+
isMax = false;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (isMax) {
|
|
344
|
+
peakIndices.push(i);
|
|
345
|
+
peakStrengths.push(normalize(currentVal));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Apply minimum distance constraint
|
|
350
|
+
const filteredIndices: number[] = [];
|
|
351
|
+
const filteredStrengths: number[] = [];
|
|
352
|
+
|
|
353
|
+
for (let i = 0; i < peakIndices.length; i++) {
|
|
354
|
+
const idx = peakIndices[i]!;
|
|
355
|
+
const time = times[idx]!;
|
|
356
|
+
const strength = peakStrengths[i]!;
|
|
357
|
+
|
|
358
|
+
let shouldKeep = true;
|
|
359
|
+
|
|
360
|
+
for (let j = 0; j < filteredIndices.length; j++) {
|
|
361
|
+
const prevIdx = filteredIndices[j]!;
|
|
362
|
+
const prevTime = times[prevIdx]!;
|
|
363
|
+
const prevStrength = filteredStrengths[j]!;
|
|
364
|
+
|
|
365
|
+
if (Math.abs(time - prevTime) < minDistance) {
|
|
366
|
+
if (strength > prevStrength) {
|
|
367
|
+
filteredIndices[j] = idx;
|
|
368
|
+
filteredStrengths[j] = strength;
|
|
369
|
+
}
|
|
370
|
+
shouldKeep = false;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (shouldKeep) {
|
|
376
|
+
filteredIndices.push(idx);
|
|
377
|
+
filteredStrengths.push(strength);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Convert to output format
|
|
382
|
+
const resultTimes = new Float32Array(filteredIndices.length);
|
|
383
|
+
const resultStrengths = new Float32Array(filteredStrengths.length);
|
|
384
|
+
|
|
385
|
+
for (let i = 0; i < filteredIndices.length; i++) {
|
|
386
|
+
resultTimes[i] = times[filteredIndices[i]!]!;
|
|
387
|
+
resultStrengths[i] = filteredStrengths[i]!;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const baseResult: PeakPickingResult = {
|
|
391
|
+
times: resultTimes,
|
|
392
|
+
strengths: resultStrengths,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
if (!includeThresholdCurve) {
|
|
396
|
+
return baseResult;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Compute normalized threshold curve for visualization
|
|
400
|
+
const normalizedThreshold = new Float32Array(values.length);
|
|
401
|
+
for (let i = 0; i < values.length; i++) {
|
|
402
|
+
normalizedThreshold[i] = range > 0
|
|
403
|
+
? Math.max(0, Math.min(1, (adaptiveThreshold[i]! - minVal) / range))
|
|
404
|
+
: 0.5;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
...baseResult,
|
|
409
|
+
thresholdCurve: normalizedThreshold,
|
|
410
|
+
thresholdTimes: times,
|
|
411
|
+
} as AdaptivePeakPickingResult;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Hysteresis gate parameters for peak filtering.
|
|
416
|
+
*/
|
|
417
|
+
export interface HysteresisGateParams {
|
|
418
|
+
/** Upper threshold (0-1) - signal must exceed this to trigger "on" state */
|
|
419
|
+
onThreshold: number;
|
|
420
|
+
/** Lower threshold (0-1) - signal must fall below this to trigger "off" state */
|
|
421
|
+
offThreshold: number;
|
|
422
|
+
/** Minimum time between peaks in seconds */
|
|
423
|
+
minDistance: number;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Apply hysteresis gating to peaks.
|
|
428
|
+
* Peaks are only kept if the signal has dropped below offThreshold since the last peak.
|
|
429
|
+
* This prevents multiple triggers during sustained high-value regions.
|
|
430
|
+
*
|
|
431
|
+
* @param times - Time values of the original signal
|
|
432
|
+
* @param values - Values of the original signal (for checking hysteresis)
|
|
433
|
+
* @param peakTimes - Times of detected peaks
|
|
434
|
+
* @param peakStrengths - Strengths of detected peaks
|
|
435
|
+
* @param params - Hysteresis gate parameters
|
|
436
|
+
* @returns Filtered peaks after hysteresis gating
|
|
437
|
+
*/
|
|
438
|
+
export function applyHysteresisGate(
|
|
439
|
+
times: Float32Array,
|
|
440
|
+
values: Float32Array,
|
|
441
|
+
peakTimes: Float32Array,
|
|
442
|
+
peakStrengths: Float32Array,
|
|
443
|
+
params: HysteresisGateParams
|
|
444
|
+
): PeakPickingResult {
|
|
445
|
+
const { onThreshold, offThreshold, minDistance } = params;
|
|
446
|
+
|
|
447
|
+
if (peakTimes.length === 0) {
|
|
448
|
+
return { times: new Float32Array(0), strengths: new Float32Array(0) };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Normalize values to 0-1 range
|
|
452
|
+
let minVal = values[0]!;
|
|
453
|
+
let maxVal = values[0]!;
|
|
454
|
+
for (let i = 1; i < values.length; i++) {
|
|
455
|
+
const v = values[i]!;
|
|
456
|
+
if (v < minVal) minVal = v;
|
|
457
|
+
if (v > maxVal) maxVal = v;
|
|
458
|
+
}
|
|
459
|
+
const range = maxVal - minVal;
|
|
460
|
+
const normalize = range > 0 ? (v: number) => (v - minVal) / range : () => 0.5;
|
|
461
|
+
|
|
462
|
+
// Build time-to-index lookup (approximate)
|
|
463
|
+
const getIndexForTime = (t: number): number => {
|
|
464
|
+
// Binary search for closest time
|
|
465
|
+
let lo = 0;
|
|
466
|
+
let hi = times.length - 1;
|
|
467
|
+
while (lo < hi) {
|
|
468
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
469
|
+
if (times[mid]! < t) {
|
|
470
|
+
lo = mid + 1;
|
|
471
|
+
} else {
|
|
472
|
+
hi = mid;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return lo;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const filteredTimes: number[] = [];
|
|
479
|
+
const filteredStrengths: number[] = [];
|
|
480
|
+
let lastPeakTime = -Infinity;
|
|
481
|
+
let gateOpen = true; // Start with gate open
|
|
482
|
+
|
|
483
|
+
for (let i = 0; i < peakTimes.length; i++) {
|
|
484
|
+
const peakTime = peakTimes[i]!;
|
|
485
|
+
const peakStrength = peakStrengths[i]!;
|
|
486
|
+
|
|
487
|
+
// Check if we've met the minimum distance requirement
|
|
488
|
+
if (peakTime - lastPeakTime < minDistance) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// If gate is closed, check if signal has dropped below offThreshold since last peak
|
|
493
|
+
if (!gateOpen) {
|
|
494
|
+
const startIdx = getIndexForTime(lastPeakTime);
|
|
495
|
+
const endIdx = getIndexForTime(peakTime);
|
|
496
|
+
|
|
497
|
+
// Check if signal dropped below offThreshold at any point
|
|
498
|
+
for (let j = startIdx; j < endIdx && j < values.length; j++) {
|
|
499
|
+
if (normalize(values[j]!) < offThreshold) {
|
|
500
|
+
gateOpen = true;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// If gate is open and peak exceeds onThreshold, keep it
|
|
507
|
+
if (gateOpen && peakStrength >= onThreshold) {
|
|
508
|
+
filteredTimes.push(peakTime);
|
|
509
|
+
filteredStrengths.push(peakStrength);
|
|
510
|
+
lastPeakTime = peakTime;
|
|
511
|
+
gateOpen = false; // Close gate after peak
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
times: new Float32Array(filteredTimes),
|
|
517
|
+
strengths: new Float32Array(filteredStrengths),
|
|
518
|
+
};
|
|
519
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase alignment algorithm for beat grid generation.
|
|
3
|
+
*
|
|
4
|
+
* Given a BPM and beat candidates, this module computes optimal phase offsets
|
|
5
|
+
* that align the beat grid with detected beat candidates.
|
|
6
|
+
*
|
|
7
|
+
* The algorithm is deterministic: same inputs always produce same outputs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { BeatCandidate, PhaseHypothesis, PhaseAlignmentConfig } from "../types";
|
|
11
|
+
|
|
12
|
+
const DEFAULT_CONFIG: Required<PhaseAlignmentConfig> = {
|
|
13
|
+
phaseResolution: 16,
|
|
14
|
+
matchTolerance: 0.05, // 50ms
|
|
15
|
+
topK: 3,
|
|
16
|
+
offsetPenaltyWeight: 0.2,
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compute phase hypotheses for a given BPM against beat candidates.
|
|
21
|
+
*
|
|
22
|
+
* Algorithm:
|
|
23
|
+
* 1. Compute beat period = 60 / bpm
|
|
24
|
+
* 2. Generate N phase offsets spanning [0, period) at resolution = period/N
|
|
25
|
+
* 3. For each phase offset:
|
|
26
|
+
* a. For each beat candidate, find closest grid line
|
|
27
|
+
* b. If within tolerance, add weighted score based on candidate strength
|
|
28
|
+
* c. Track systematic offset error for penalty
|
|
29
|
+
* 4. Normalize scores and apply offset penalty
|
|
30
|
+
* 5. Return top K phases sorted by score
|
|
31
|
+
*
|
|
32
|
+
* @param bpm - Tempo in beats per minute
|
|
33
|
+
* @param candidates - Beat candidate events with time and strength
|
|
34
|
+
* @param audioDuration - Total audio duration in seconds
|
|
35
|
+
* @param config - Optional configuration overrides
|
|
36
|
+
* @returns Array of top K phase hypotheses, sorted by score descending
|
|
37
|
+
*/
|
|
38
|
+
export function computePhaseHypotheses(
|
|
39
|
+
bpm: number,
|
|
40
|
+
candidates: BeatCandidate[],
|
|
41
|
+
audioDuration: number,
|
|
42
|
+
config?: PhaseAlignmentConfig
|
|
43
|
+
): PhaseHypothesis[] {
|
|
44
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
45
|
+
const period = 60 / bpm;
|
|
46
|
+
|
|
47
|
+
if (candidates.length === 0 || audioDuration <= 0 || bpm <= 0) {
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const phases: PhaseHypothesis[] = [];
|
|
52
|
+
|
|
53
|
+
// Generate and score phase candidates
|
|
54
|
+
for (let i = 0; i < cfg.phaseResolution; i++) {
|
|
55
|
+
const phaseOffset = (i / cfg.phaseResolution) * period;
|
|
56
|
+
const result = scorePhase(phaseOffset, period, candidates, audioDuration, cfg.matchTolerance);
|
|
57
|
+
phases.push({
|
|
58
|
+
index: i,
|
|
59
|
+
phaseOffset,
|
|
60
|
+
score: result.score,
|
|
61
|
+
matchCount: result.matchCount,
|
|
62
|
+
avgOffsetError: result.avgOffsetError,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Normalize scores to [0, 1] and apply offset penalty
|
|
67
|
+
const maxScore = Math.max(...phases.map((p) => p.score), 1e-9);
|
|
68
|
+
for (const phase of phases) {
|
|
69
|
+
// Penalty based on systematic offset (how far off-center matches are)
|
|
70
|
+
const penalty = (phase.avgOffsetError / cfg.matchTolerance) * cfg.offsetPenaltyWeight;
|
|
71
|
+
phase.score = (phase.score / maxScore) * (1 - Math.min(1, penalty));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Sort by score descending and take top K
|
|
75
|
+
phases.sort((a, b) => b.score - a.score);
|
|
76
|
+
return phases.slice(0, cfg.topK);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Score a single phase offset against beat candidates.
|
|
81
|
+
*/
|
|
82
|
+
function scorePhase(
|
|
83
|
+
phaseOffset: number,
|
|
84
|
+
period: number,
|
|
85
|
+
candidates: BeatCandidate[],
|
|
86
|
+
audioDuration: number,
|
|
87
|
+
tolerance: number
|
|
88
|
+
): { score: number; matchCount: number; avgOffsetError: number } {
|
|
89
|
+
let score = 0;
|
|
90
|
+
let matchCount = 0;
|
|
91
|
+
let totalOffsetError = 0;
|
|
92
|
+
|
|
93
|
+
for (const candidate of candidates) {
|
|
94
|
+
// Find the closest grid beat to this candidate
|
|
95
|
+
const beatsFromStart = (candidate.time - phaseOffset) / period;
|
|
96
|
+
const nearestBeatIndex = Math.round(beatsFromStart);
|
|
97
|
+
const nearestBeatTime = phaseOffset + nearestBeatIndex * period;
|
|
98
|
+
|
|
99
|
+
// Skip if the nearest beat is outside the audio range
|
|
100
|
+
if (nearestBeatTime < 0 || nearestBeatTime > audioDuration) continue;
|
|
101
|
+
|
|
102
|
+
const offset = Math.abs(candidate.time - nearestBeatTime);
|
|
103
|
+
|
|
104
|
+
if (offset <= tolerance) {
|
|
105
|
+
// Gaussian-like weighting: closer matches score higher
|
|
106
|
+
const weight = Math.exp((-offset * offset) / (2 * tolerance * tolerance));
|
|
107
|
+
score += candidate.strength * weight;
|
|
108
|
+
matchCount++;
|
|
109
|
+
totalOffsetError += offset;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const avgOffsetError = matchCount > 0 ? totalOffsetError / matchCount : 0;
|
|
114
|
+
|
|
115
|
+
return { score, matchCount, avgOffsetError };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Generate beat times for a given grid within the audio duration.
|
|
120
|
+
*
|
|
121
|
+
* @param bpm - Tempo in beats per minute
|
|
122
|
+
* @param phaseOffset - Base phase offset in seconds
|
|
123
|
+
* @param userNudge - User adjustment in seconds (additive)
|
|
124
|
+
* @param audioDuration - Total audio duration in seconds
|
|
125
|
+
* @returns Array of beat times in seconds
|
|
126
|
+
*/
|
|
127
|
+
export function generateBeatTimes(
|
|
128
|
+
bpm: number,
|
|
129
|
+
phaseOffset: number,
|
|
130
|
+
userNudge: number,
|
|
131
|
+
audioDuration: number
|
|
132
|
+
): number[] {
|
|
133
|
+
if (bpm <= 0 || audioDuration <= 0) {
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const period = 60 / bpm;
|
|
138
|
+
const effectivePhase = phaseOffset + userNudge;
|
|
139
|
+
const beats: number[] = [];
|
|
140
|
+
|
|
141
|
+
// Find the first beat at or after time 0
|
|
142
|
+
const firstBeatIndex = Math.ceil(-effectivePhase / period);
|
|
143
|
+
let time = effectivePhase + firstBeatIndex * period;
|
|
144
|
+
|
|
145
|
+
while (time <= audioDuration) {
|
|
146
|
+
if (time >= 0) {
|
|
147
|
+
beats.push(time);
|
|
148
|
+
}
|
|
149
|
+
time += period;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return beats;
|
|
153
|
+
}
|