@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,662 @@
1
+ /**
2
+ * Band-Scoped CQT utilities for F3.
3
+ *
4
+ * These functions compute CQT-derived features (harmonic energy, bass pitch motion,
5
+ * tonal stability) for frequency bands by applying spectral masks to a CQT
6
+ * spectrogram.
7
+ *
8
+ * CQT uses log-frequency bins, so band masking requires mapping Hz ranges
9
+ * to CQT bin indices using hzToCqtBin().
10
+ */
11
+
12
+ import type {
13
+ FrequencyBand,
14
+ CqtSpectrogram,
15
+ CqtConfig,
16
+ MirRunMeta,
17
+ MirRunTimings,
18
+ BandCqtFunctionId,
19
+ BandMirDiagnostics,
20
+ BandCqt1DResult,
21
+ } from "../types";
22
+ import { hzToCqtBin, cqtBinToHz } from "./cqt";
23
+
24
+ // Re-export types for convenience
25
+ export type { BandCqtFunctionId, BandCqt1DResult };
26
+
27
+ // ----------------------------
28
+ // Options
29
+ // ----------------------------
30
+
31
+ export type BandCqtOptions = {
32
+ /** Soft edge width in bins for mask transitions. Default: 0 */
33
+ edgeSmoothBins?: number;
34
+ /** Optional cancellation hook */
35
+ isCancelled?: () => boolean;
36
+ };
37
+
38
+ // ----------------------------
39
+ // Internal Helpers
40
+ // ----------------------------
41
+
42
+ function nowMs(): number {
43
+ return typeof performance !== "undefined" ? performance.now() : Date.now();
44
+ }
45
+
46
+ function createMeta(startMs: number): MirRunMeta {
47
+ const endMs = nowMs();
48
+ const timings: MirRunTimings = {
49
+ totalMs: endMs - startMs,
50
+ cpuMs: endMs - startMs,
51
+ gpuMs: 0,
52
+ };
53
+ return {
54
+ backend: "cpu",
55
+ usedGpu: false,
56
+ timings,
57
+ };
58
+ }
59
+
60
+ function computeDiagnostics(
61
+ energyRetainedPerFrame: Float32Array
62
+ ): BandMirDiagnostics {
63
+ const totalFrames = energyRetainedPerFrame.length;
64
+ let sum = 0;
65
+ let weakCount = 0;
66
+ let emptyCount = 0;
67
+
68
+ for (let i = 0; i < totalFrames; i++) {
69
+ const e = energyRetainedPerFrame[i] ?? 0;
70
+ sum += e;
71
+ if (e < 0.01) weakCount++;
72
+ if (e === 0) emptyCount++;
73
+ }
74
+
75
+ const meanEnergyRetained = totalFrames > 0 ? sum / totalFrames : 0;
76
+ const warnings: string[] = [];
77
+
78
+ if (meanEnergyRetained < 0.01) {
79
+ warnings.push("Band contains very little CQT energy - check frequency range");
80
+ }
81
+ if (emptyCount > totalFrames * 0.5) {
82
+ warnings.push("More than half of frames are empty in CQT - band may not be active");
83
+ }
84
+
85
+ return {
86
+ meanEnergyRetained,
87
+ weakFrameCount: weakCount,
88
+ emptyFrameCount: emptyCount,
89
+ totalFrames,
90
+ warnings,
91
+ };
92
+ }
93
+
94
+ /**
95
+ * Get the frequency bounds at a specific time for a band.
96
+ * Returns the bounds from the first matching segment, or the band's overall bounds.
97
+ */
98
+ function getBandBoundsAtTime(
99
+ band: FrequencyBand,
100
+ timeSec: number
101
+ ): { lowHz: number; highHz: number } {
102
+ // Find the segment containing this time
103
+ for (const seg of band.frequencyShape) {
104
+ if (timeSec >= seg.startTime && timeSec < seg.endTime) {
105
+ // Linear interpolation within segment
106
+ const ratio = (timeSec - seg.startTime) / (seg.endTime - seg.startTime);
107
+ const lowHz = seg.lowHzStart + ratio * (seg.lowHzEnd - seg.lowHzStart);
108
+ const highHz = seg.highHzStart + ratio * (seg.highHzEnd - seg.highHzStart);
109
+ return { lowHz, highHz };
110
+ }
111
+ }
112
+
113
+ // Fallback to first segment bounds
114
+ const first = band.frequencyShape[0];
115
+ if (first) {
116
+ return { lowHz: first.lowHzStart, highHz: first.highHzStart };
117
+ }
118
+
119
+ // Ultimate fallback
120
+ return { lowHz: 20, highHz: 20000 };
121
+ }
122
+
123
+ // ----------------------------
124
+ // CQT Band Masking
125
+ // ----------------------------
126
+
127
+ export type MaskedCqtSpectrogram = {
128
+ /** Original CQT times */
129
+ times: Float32Array;
130
+ /** Masked CQT magnitudes */
131
+ magnitudes: Float32Array[];
132
+ /** CQT config */
133
+ config: CqtConfig;
134
+ /** Energy retained per frame (0-1) */
135
+ energyRetainedPerFrame: Float32Array;
136
+ /** CQT bin frequencies */
137
+ binFrequencies: Float32Array;
138
+ };
139
+
140
+ /**
141
+ * Apply a frequency band mask to a CQT spectrogram.
142
+ *
143
+ * Maps the band's Hz range to CQT bin indices and masks accordingly.
144
+ *
145
+ * @param cqt - Source CQT spectrogram
146
+ * @param band - Frequency band to apply
147
+ * @param options - Masking options
148
+ * @returns Masked CQT spectrogram with energy retention diagnostics
149
+ */
150
+ export function applyBandMaskToCqt(
151
+ cqt: CqtSpectrogram,
152
+ band: FrequencyBand,
153
+ options?: BandCqtOptions
154
+ ): MaskedCqtSpectrogram {
155
+ const edgeSmoothBins = options?.edgeSmoothBins ?? 0;
156
+
157
+ const nFrames = cqt.times.length;
158
+ const nBins = cqt.binFrequencies.length;
159
+
160
+ const maskedMagnitudes: Float32Array[] = new Array(nFrames);
161
+ const energyRetainedPerFrame = new Float32Array(nFrames);
162
+
163
+ for (let t = 0; t < nFrames; t++) {
164
+ const frame = cqt.magnitudes[t];
165
+ if (!frame) {
166
+ maskedMagnitudes[t] = new Float32Array(nBins);
167
+ energyRetainedPerFrame[t] = 0;
168
+ continue;
169
+ }
170
+
171
+ const timeSec = cqt.times[t] ?? 0;
172
+ const { lowHz, highHz } = getBandBoundsAtTime(band, timeSec);
173
+
174
+ // Map Hz to CQT bin indices
175
+ const lowBin = Math.max(0, Math.floor(hzToCqtBin(lowHz, cqt.config)));
176
+ const highBin = Math.min(nBins, Math.ceil(hzToCqtBin(highHz, cqt.config)));
177
+
178
+ const maskedFrame = new Float32Array(nBins);
179
+ let originalEnergy = 0;
180
+ let retainedEnergy = 0;
181
+
182
+ for (let k = 0; k < nBins; k++) {
183
+ const mag = frame[k] ?? 0;
184
+ originalEnergy += mag * mag;
185
+
186
+ if (k >= lowBin && k < highBin) {
187
+ // Apply soft edges if requested
188
+ let weight = 1;
189
+ if (edgeSmoothBins > 0) {
190
+ const distFromLow = k - lowBin;
191
+ const distFromHigh = highBin - 1 - k;
192
+ const minDist = Math.min(distFromLow, distFromHigh);
193
+ if (minDist < edgeSmoothBins) {
194
+ weight = (minDist + 1) / (edgeSmoothBins + 1);
195
+ }
196
+ }
197
+ maskedFrame[k] = mag * weight;
198
+ retainedEnergy += (mag * weight) ** 2;
199
+ }
200
+ }
201
+
202
+ maskedMagnitudes[t] = maskedFrame;
203
+ energyRetainedPerFrame[t] = originalEnergy > 0
204
+ ? Math.sqrt(retainedEnergy / originalEnergy)
205
+ : 0;
206
+ }
207
+
208
+ return {
209
+ times: cqt.times,
210
+ magnitudes: maskedMagnitudes,
211
+ config: cqt.config,
212
+ energyRetainedPerFrame,
213
+ binFrequencies: cqt.binFrequencies,
214
+ };
215
+ }
216
+
217
+ // ----------------------------
218
+ // CQT Band Signal Functions
219
+ // ----------------------------
220
+
221
+ /**
222
+ * Compute harmonic energy for a frequency band using CQT.
223
+ *
224
+ * Measures the ratio of energy at harmonic intervals within the band.
225
+ * Adapted from cqtSignals.ts:harmonicEnergy.
226
+ *
227
+ * @param cqt - Source CQT spectrogram
228
+ * @param band - Frequency band to analyze
229
+ * @param options - Computation options
230
+ * @returns Band CQT result with harmonic energy
231
+ */
232
+ export function bandCqtHarmonicEnergy(
233
+ cqt: CqtSpectrogram,
234
+ band: FrequencyBand,
235
+ options?: BandCqtOptions
236
+ ): BandCqt1DResult {
237
+ const startMs = nowMs();
238
+
239
+ // Apply band mask to CQT
240
+ const masked = applyBandMaskToCqt(cqt, band, options);
241
+
242
+ const nFrames = masked.times.length;
243
+ const values = new Float32Array(nFrames);
244
+
245
+ for (let t = 0; t < nFrames; t++) {
246
+ if (options?.isCancelled?.()) {
247
+ throw new Error("@octoseq/mir: cancelled");
248
+ }
249
+
250
+ const frame = masked.magnitudes[t];
251
+ if (!frame) {
252
+ values[t] = 0;
253
+ continue;
254
+ }
255
+
256
+ // Find total energy
257
+ let totalEnergy = 0;
258
+ for (let k = 0; k < frame.length; k++) {
259
+ const mag = frame[k] ?? 0;
260
+ totalEnergy += mag * mag;
261
+ }
262
+
263
+ if (totalEnergy === 0) {
264
+ values[t] = 0;
265
+ continue;
266
+ }
267
+
268
+ // Find strongest bin as fundamental candidate
269
+ let maxMag = 0;
270
+ let fundamentalBin = 0;
271
+ for (let k = 0; k < frame.length; k++) {
272
+ const mag = frame[k] ?? 0;
273
+ if (mag > maxMag) {
274
+ maxMag = mag;
275
+ fundamentalBin = k;
276
+ }
277
+ }
278
+
279
+ const fundamentalFreq = cqtBinToHz(fundamentalBin, cqt.config);
280
+
281
+ // Sum energy at harmonic positions
282
+ let harmonicEnergy = 0;
283
+ const numHarmonics = 6;
284
+
285
+ for (let h = 1; h <= numHarmonics; h++) {
286
+ const harmonicFreq = fundamentalFreq * h;
287
+ const harmonicBin = Math.round(hzToCqtBin(harmonicFreq, cqt.config));
288
+
289
+ if (harmonicBin >= 0 && harmonicBin < frame.length) {
290
+ const mag = frame[harmonicBin] ?? 0;
291
+ const weight = 1 / h;
292
+ harmonicEnergy += mag * mag * weight;
293
+ }
294
+ }
295
+
296
+ // Normalize by expected harmonic weight sum
297
+ let weightSum = 0;
298
+ for (let h = 1; h <= numHarmonics; h++) {
299
+ weightSum += 1 / h;
300
+ }
301
+ harmonicEnergy /= weightSum;
302
+
303
+ values[t] = Math.min(1, harmonicEnergy / totalEnergy);
304
+ }
305
+
306
+ // Normalize to [0, 1] using min-max
307
+ const normalized = normalizeMinMax(values);
308
+
309
+ return {
310
+ kind: "bandCqt1d",
311
+ bandId: band.id,
312
+ bandLabel: band.label,
313
+ fn: "bandCqtHarmonicEnergy",
314
+ times: masked.times,
315
+ values: normalized,
316
+ meta: createMeta(startMs),
317
+ diagnostics: computeDiagnostics(masked.energyRetainedPerFrame),
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Compute bass pitch motion for a frequency band using CQT.
323
+ *
324
+ * Measures pitch movement in bass-range CQT bins within the band.
325
+ * Most meaningful for bands overlapping the bass range (20-300 Hz).
326
+ *
327
+ * @param cqt - Source CQT spectrogram
328
+ * @param band - Frequency band to analyze
329
+ * @param options - Computation options
330
+ * @returns Band CQT result with bass pitch motion
331
+ */
332
+ export function bandCqtBassPitchMotion(
333
+ cqt: CqtSpectrogram,
334
+ band: FrequencyBand,
335
+ options?: BandCqtOptions
336
+ ): BandCqt1DResult {
337
+ const startMs = nowMs();
338
+
339
+ // Apply band mask to CQT
340
+ const masked = applyBandMaskToCqt(cqt, band, options);
341
+
342
+ const nFrames = masked.times.length;
343
+
344
+ // Bass range constants
345
+ const BASS_MIN_HZ = 20;
346
+ const BASS_MAX_HZ = 300;
347
+
348
+ // Find bass bin range within band
349
+ const bassStartBin = Math.max(0, Math.floor(hzToCqtBin(BASS_MIN_HZ, cqt.config)));
350
+ const bassEndBin = Math.min(
351
+ masked.binFrequencies.length,
352
+ Math.ceil(hzToCqtBin(BASS_MAX_HZ, cqt.config))
353
+ );
354
+ const bassNumBins = bassEndBin - bassStartBin;
355
+
356
+ if (bassNumBins <= 0) {
357
+ // Band doesn't overlap bass range
358
+ return {
359
+ kind: "bandCqt1d",
360
+ bandId: band.id,
361
+ bandLabel: band.label,
362
+ fn: "bandCqtBassPitchMotion",
363
+ times: masked.times,
364
+ values: new Float32Array(nFrames),
365
+ meta: createMeta(startMs),
366
+ diagnostics: {
367
+ ...computeDiagnostics(masked.energyRetainedPerFrame),
368
+ warnings: ["Band does not overlap bass frequency range (20-300 Hz)"],
369
+ },
370
+ };
371
+ }
372
+
373
+ // Compute bass centroid for each frame
374
+ const centroids = new Float32Array(nFrames);
375
+
376
+ for (let t = 0; t < nFrames; t++) {
377
+ const frame = masked.magnitudes[t];
378
+ if (!frame) continue;
379
+
380
+ // Compute weighted centroid in bass range
381
+ let num = 0;
382
+ let den = 0;
383
+
384
+ for (let k = bassStartBin; k < bassEndBin; k++) {
385
+ const mag = frame[k] ?? 0;
386
+ if (mag > 0) {
387
+ num += k * mag;
388
+ den += mag;
389
+ }
390
+ }
391
+
392
+ centroids[t] = den > 0 ? num / den : bassStartBin + bassNumBins / 2;
393
+ }
394
+
395
+ // Compute motion as absolute difference between consecutive frames
396
+ const motion = new Float32Array(nFrames);
397
+ for (let t = 1; t < nFrames; t++) {
398
+ motion[t] = Math.abs((centroids[t] ?? 0) - (centroids[t - 1] ?? 0));
399
+ }
400
+ if (nFrames > 1) {
401
+ motion[0] = motion[1] ?? 0;
402
+ }
403
+
404
+ // Normalize to [0, 1]
405
+ const normalized = normalizeMinMax(motion);
406
+
407
+ return {
408
+ kind: "bandCqt1d",
409
+ bandId: band.id,
410
+ bandLabel: band.label,
411
+ fn: "bandCqtBassPitchMotion",
412
+ times: masked.times,
413
+ values: normalized,
414
+ meta: createMeta(startMs),
415
+ diagnostics: computeDiagnostics(masked.energyRetainedPerFrame),
416
+ };
417
+ }
418
+
419
+ /**
420
+ * Compute tonal stability for a frequency band using CQT.
421
+ *
422
+ * Measures consistency of chroma distribution over time within the band.
423
+ * Adapted from cqtSignals.ts:tonalStability.
424
+ *
425
+ * @param cqt - Source CQT spectrogram
426
+ * @param band - Frequency band to analyze
427
+ * @param options - Computation options
428
+ * @returns Band CQT result with tonal stability
429
+ */
430
+ export function bandCqtTonalStability(
431
+ cqt: CqtSpectrogram,
432
+ band: FrequencyBand,
433
+ options?: BandCqtOptions
434
+ ): BandCqt1DResult {
435
+ const startMs = nowMs();
436
+
437
+ // Apply band mask to CQT
438
+ const masked = applyBandMaskToCqt(cqt, band, options);
439
+
440
+ const nFrames = masked.times.length;
441
+ const CHROMA_BINS = 12;
442
+ const WINDOW_FRAMES = 20; // ~500ms at typical hop sizes
443
+
444
+ // Compute chroma for each frame
445
+ const chromas: Float32Array[] = new Array(nFrames);
446
+
447
+ for (let t = 0; t < nFrames; t++) {
448
+ const frame = masked.magnitudes[t];
449
+ const chroma = new Float32Array(CHROMA_BINS);
450
+
451
+ if (frame) {
452
+ const binsPerSemitone = cqt.binsPerOctave / CHROMA_BINS;
453
+
454
+ for (let k = 0; k < frame.length; k++) {
455
+ const chromaBin = Math.floor((k % cqt.binsPerOctave) / binsPerSemitone) % CHROMA_BINS;
456
+ const mag = frame[k] ?? 0;
457
+ chroma[chromaBin] = (chroma[chromaBin] ?? 0) + mag * mag;
458
+ }
459
+
460
+ // Normalize
461
+ let sum = 0;
462
+ for (let c = 0; c < CHROMA_BINS; c++) {
463
+ sum += chroma[c] ?? 0;
464
+ }
465
+ if (sum > 0) {
466
+ for (let c = 0; c < CHROMA_BINS; c++) {
467
+ chroma[c] = (chroma[c] ?? 0) / sum;
468
+ }
469
+ }
470
+ }
471
+
472
+ chromas[t] = chroma;
473
+ }
474
+
475
+ // Compute stability over sliding window
476
+ const halfWindow = Math.floor(WINDOW_FRAMES / 2);
477
+ const instability = new Float32Array(nFrames);
478
+
479
+ for (let t = 0; t < nFrames; t++) {
480
+ const windowStart = Math.max(0, t - halfWindow);
481
+ const windowEnd = Math.min(nFrames, t + halfWindow + 1);
482
+ const windowSize = windowEnd - windowStart;
483
+
484
+ // Average chroma over window
485
+ const avgChroma = new Float32Array(CHROMA_BINS);
486
+ for (let w = windowStart; w < windowEnd; w++) {
487
+ const chroma = chromas[w];
488
+ if (chroma) {
489
+ for (let c = 0; c < CHROMA_BINS; c++) {
490
+ avgChroma[c] = (avgChroma[c] ?? 0) + (chroma[c] ?? 0);
491
+ }
492
+ }
493
+ }
494
+ for (let c = 0; c < CHROMA_BINS; c++) {
495
+ avgChroma[c] = (avgChroma[c] ?? 0) / windowSize;
496
+ }
497
+
498
+ // Compute variance
499
+ let totalVariance = 0;
500
+ for (let w = windowStart; w < windowEnd; w++) {
501
+ const chroma = chromas[w];
502
+ if (chroma) {
503
+ for (let c = 0; c < CHROMA_BINS; c++) {
504
+ const diff = (chroma[c] ?? 0) - (avgChroma[c] ?? 0);
505
+ totalVariance += diff * diff;
506
+ }
507
+ }
508
+ }
509
+ totalVariance /= windowSize * CHROMA_BINS;
510
+
511
+ instability[t] = totalVariance;
512
+ }
513
+
514
+ // Normalize instability and invert to get stability
515
+ const normalizedInstability = normalizeMinMax(instability);
516
+ const stability = new Float32Array(nFrames);
517
+ for (let t = 0; t < nFrames; t++) {
518
+ stability[t] = 1 - (normalizedInstability[t] ?? 0);
519
+ }
520
+
521
+ return {
522
+ kind: "bandCqt1d",
523
+ bandId: band.id,
524
+ bandLabel: band.label,
525
+ fn: "bandCqtTonalStability",
526
+ times: masked.times,
527
+ values: stability,
528
+ meta: createMeta(startMs),
529
+ diagnostics: computeDiagnostics(masked.energyRetainedPerFrame),
530
+ };
531
+ }
532
+
533
+ // ----------------------------
534
+ // Utility Functions
535
+ // ----------------------------
536
+
537
+ function normalizeMinMax(values: Float32Array): Float32Array {
538
+ const n = values.length;
539
+ if (n === 0) return new Float32Array(0);
540
+
541
+ let min = Infinity;
542
+ let max = -Infinity;
543
+ for (let i = 0; i < n; i++) {
544
+ const v = values[i] ?? 0;
545
+ if (v < min) min = v;
546
+ if (v > max) max = v;
547
+ }
548
+
549
+ const out = new Float32Array(n);
550
+ const range = max - min;
551
+
552
+ if (range === 0 || !Number.isFinite(range)) {
553
+ out.fill(0.5);
554
+ return out;
555
+ }
556
+
557
+ for (let i = 0; i < n; i++) {
558
+ out[i] = ((values[i] ?? 0) - min) / range;
559
+ }
560
+
561
+ return out;
562
+ }
563
+
564
+ // ----------------------------
565
+ // Batch Runner
566
+ // ----------------------------
567
+
568
+ export type BandCqtBatchRequest = {
569
+ bands: FrequencyBand[];
570
+ functions: BandCqtFunctionId[];
571
+ /** Maximum number of bands to process concurrently. Default: 4 */
572
+ maxConcurrent?: number;
573
+ };
574
+
575
+ export type BandCqtBatchResult = {
576
+ /** Results keyed by bandId */
577
+ results: Map<string, BandCqt1DResult[]>;
578
+ /** Total computation time in ms */
579
+ totalTimingMs: number;
580
+ };
581
+
582
+ /**
583
+ * Run band CQT analysis for multiple bands.
584
+ *
585
+ * @param cqt - Source CQT spectrogram
586
+ * @param request - Batch request specifying bands and functions
587
+ * @param options - Computation options
588
+ * @returns Map of results by band ID
589
+ */
590
+ export async function runBandCqtBatch(
591
+ cqt: CqtSpectrogram,
592
+ request: BandCqtBatchRequest,
593
+ options?: BandCqtOptions
594
+ ): Promise<BandCqtBatchResult> {
595
+ const startMs = nowMs();
596
+
597
+ const results = new Map<string, BandCqt1DResult[]>();
598
+
599
+ for (const band of request.bands) {
600
+ if (options?.isCancelled?.()) {
601
+ throw new Error("@octoseq/mir: cancelled");
602
+ }
603
+
604
+ if (!band.enabled) continue;
605
+
606
+ const bandResults: BandCqt1DResult[] = [];
607
+
608
+ for (const fn of request.functions) {
609
+ if (options?.isCancelled?.()) {
610
+ throw new Error("@octoseq/mir: cancelled");
611
+ }
612
+
613
+ let result: BandCqt1DResult;
614
+
615
+ switch (fn) {
616
+ case "bandCqtHarmonicEnergy":
617
+ result = bandCqtHarmonicEnergy(cqt, band, options);
618
+ break;
619
+ case "bandCqtBassPitchMotion":
620
+ result = bandCqtBassPitchMotion(cqt, band, options);
621
+ break;
622
+ case "bandCqtTonalStability":
623
+ result = bandCqtTonalStability(cqt, band, options);
624
+ break;
625
+ default:
626
+ // Exhaustive check
627
+ const _exhaustive: never = fn;
628
+ throw new Error(`Unknown band CQT function: ${_exhaustive}`);
629
+ }
630
+
631
+ bandResults.push(result);
632
+ }
633
+
634
+ results.set(band.id, bandResults);
635
+ }
636
+
637
+ const endMs = nowMs();
638
+
639
+ return {
640
+ results,
641
+ totalTimingMs: endMs - startMs,
642
+ };
643
+ }
644
+
645
+ /**
646
+ * Get a human-readable label for a band CQT function.
647
+ *
648
+ * @param fn - Band CQT function ID
649
+ * @returns Human-readable label
650
+ */
651
+ export function getBandCqtFunctionLabel(fn: BandCqtFunctionId): string {
652
+ switch (fn) {
653
+ case "bandCqtHarmonicEnergy":
654
+ return "Harmonic Energy (CQT)";
655
+ case "bandCqtBassPitchMotion":
656
+ return "Bass Pitch Motion (CQT)";
657
+ case "bandCqtTonalStability":
658
+ return "Tonal Stability (CQT)";
659
+ default:
660
+ return fn;
661
+ }
662
+ }