@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,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
|
+
}
|