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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/chunk-HF3QHCRK.js +3234 -0
  2. package/dist/chunk-HF3QHCRK.js.map +1 -0
  3. package/dist/index.d.ts +2122 -47
  4. package/dist/index.js +3524 -39
  5. package/dist/index.js.map +1 -1
  6. package/dist/runMir-CnJQbBr8.d.ts +187 -0
  7. package/dist/runner/runMir.d.ts +2 -2
  8. package/dist/runner/runMir.js +1 -1
  9. package/dist/runner/workerProtocol.d.ts +8 -1
  10. package/dist/runner/workerProtocol.js.map +1 -1
  11. package/dist/types-DqH4umN8.d.ts +761 -0
  12. package/package.json +2 -2
  13. package/src/dsp/activity.ts +544 -0
  14. package/src/dsp/bandCqt.ts +662 -0
  15. package/src/dsp/bandEvents.ts +351 -0
  16. package/src/dsp/bandMask.ts +225 -0
  17. package/src/dsp/bandMir.ts +524 -0
  18. package/src/dsp/bandProposal.ts +552 -0
  19. package/src/dsp/beatCandidates.ts +299 -0
  20. package/src/dsp/cqt.ts +386 -0
  21. package/src/dsp/cqtSignals.ts +462 -0
  22. package/src/dsp/customSignalReduction.ts +841 -0
  23. package/src/dsp/eventToSignal.ts +531 -0
  24. package/src/dsp/frequencyBand.ts +956 -0
  25. package/src/dsp/mel.ts +56 -3
  26. package/src/dsp/musicalTime.ts +240 -0
  27. package/src/dsp/onset.ts +296 -30
  28. package/src/dsp/peakPicking.ts +519 -0
  29. package/src/dsp/phaseAlignment.ts +153 -0
  30. package/src/dsp/pitch.ts +289 -0
  31. package/src/dsp/resample.ts +44 -0
  32. package/src/dsp/signalTransforms.ts +660 -0
  33. package/src/dsp/silenceGating.ts +511 -0
  34. package/src/dsp/spectral.ts +124 -4
  35. package/src/dsp/tempoHypotheses.ts +395 -0
  36. package/src/gpu/bufferPool.ts +266 -0
  37. package/src/gpu/context.ts +30 -6
  38. package/src/gpu/helpers.ts +85 -0
  39. package/src/gpu/melProject.ts +83 -0
  40. package/src/index.ts +366 -5
  41. package/src/runner/runMir.ts +234 -2
  42. package/src/runner/workerProtocol.ts +9 -1
  43. package/src/types.ts +768 -3
  44. package/dist/chunk-DUWYCAVG.js +0 -1525
  45. package/dist/chunk-DUWYCAVG.js.map +0 -1
  46. package/dist/runMir-CSIBwNZ3.d.ts +0 -84
  47. package/dist/types-BE3py4fZ.d.ts +0 -83
@@ -0,0 +1,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
+ }