@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,299 @@
|
|
|
1
|
+
import type { MelSpectrogram } from "./mel";
|
|
2
|
+
import type { OnsetEnvelope } from "./onset";
|
|
3
|
+
import { onsetEnvelopeFromMel } from "./onset";
|
|
4
|
+
import type { Spectrogram } from "./spectrogram";
|
|
5
|
+
import { spectralFlux } from "./spectral";
|
|
6
|
+
import type { BeatCandidate, BeatCandidateSource } from "../types";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Configuration for beat candidate detection.
|
|
10
|
+
*/
|
|
11
|
+
export type BeatCandidatesOptions = {
|
|
12
|
+
/** Minimum inter-candidate interval in seconds. Default: 0.1 (100ms). */
|
|
13
|
+
minIntervalSec?: number;
|
|
14
|
+
/** Threshold factor for adaptive peak detection. Lower = more candidates. Default: 0.5. */
|
|
15
|
+
thresholdFactor?: number;
|
|
16
|
+
/** Smoothing window for salience signal in ms. Default: 50. */
|
|
17
|
+
smoothMs?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Result of beat candidate detection.
|
|
22
|
+
*/
|
|
23
|
+
export type BeatCandidatesOutput = {
|
|
24
|
+
candidates: BeatCandidate[];
|
|
25
|
+
/** The computed salience signal (for debugging/visualization). */
|
|
26
|
+
salience: {
|
|
27
|
+
times: Float32Array;
|
|
28
|
+
values: Float32Array;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Compute a beat-oriented salience signal from mel spectrogram.
|
|
34
|
+
*
|
|
35
|
+
* This combines:
|
|
36
|
+
* - Onset envelope (captures transients/attacks)
|
|
37
|
+
* - Spectral flux from the underlying spectrogram (captures spectral change)
|
|
38
|
+
*
|
|
39
|
+
* The signals are normalized and combined to produce a single salience curve
|
|
40
|
+
* suitable for peak picking.
|
|
41
|
+
*
|
|
42
|
+
* Key design choices:
|
|
43
|
+
* - Whole-track normalization (z-score) for consistent behavior
|
|
44
|
+
* - Gentle smoothing to suppress micro-transients while preserving beat structure
|
|
45
|
+
* - No BPM inference or grid assumptions
|
|
46
|
+
*/
|
|
47
|
+
export type BeatSalienceSignal = {
|
|
48
|
+
times: Float32Array;
|
|
49
|
+
values: Float32Array;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function movingAverage(values: Float32Array, windowFrames: number): Float32Array {
|
|
53
|
+
if (windowFrames <= 1) return values;
|
|
54
|
+
|
|
55
|
+
const n = values.length;
|
|
56
|
+
const out = new Float32Array(n);
|
|
57
|
+
|
|
58
|
+
const half = Math.floor(windowFrames / 2);
|
|
59
|
+
|
|
60
|
+
// Prefix sums for O(n) moving average.
|
|
61
|
+
const prefix = new Float64Array(n + 1);
|
|
62
|
+
prefix[0] = 0;
|
|
63
|
+
for (let i = 0; i < n; i++) {
|
|
64
|
+
prefix[i + 1] = (prefix[i] ?? 0) + (values[i] ?? 0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (let i = 0; i < n; i++) {
|
|
68
|
+
const start = Math.max(0, i - half);
|
|
69
|
+
const end = Math.min(n, i + half + 1);
|
|
70
|
+
const sum = (prefix[end] ?? 0) - (prefix[start] ?? 0);
|
|
71
|
+
const count = Math.max(1, end - start);
|
|
72
|
+
out[i] = sum / count;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function meanStd(values: Float32Array): { mean: number; std: number } {
|
|
79
|
+
const n = values.length;
|
|
80
|
+
if (n <= 0) return { mean: 0, std: 0 };
|
|
81
|
+
|
|
82
|
+
let mean = 0;
|
|
83
|
+
for (let i = 0; i < n; i++) mean += values[i] ?? 0;
|
|
84
|
+
mean /= n;
|
|
85
|
+
|
|
86
|
+
let varSum = 0;
|
|
87
|
+
for (let i = 0; i < n; i++) {
|
|
88
|
+
const d = (values[i] ?? 0) - mean;
|
|
89
|
+
varSum += d * d;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const std = Math.sqrt(varSum / n);
|
|
93
|
+
return { mean, std };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Z-score normalize a signal (whole-track normalization).
|
|
98
|
+
* Result has mean ~0 and std ~1.
|
|
99
|
+
*/
|
|
100
|
+
function zScoreNormalize(values: Float32Array): Float32Array {
|
|
101
|
+
const { mean, std } = meanStd(values);
|
|
102
|
+
const n = values.length;
|
|
103
|
+
const out = new Float32Array(n);
|
|
104
|
+
|
|
105
|
+
if (std === 0 || !Number.isFinite(std)) {
|
|
106
|
+
// Degenerate case: all values are the same
|
|
107
|
+
out.fill(0);
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < n; i++) {
|
|
112
|
+
out[i] = ((values[i] ?? 0) - mean) / std;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Min-max normalize to [0, 1] range.
|
|
120
|
+
*/
|
|
121
|
+
function minMaxNormalize(values: Float32Array): Float32Array {
|
|
122
|
+
const n = values.length;
|
|
123
|
+
if (n === 0) return new Float32Array(0);
|
|
124
|
+
|
|
125
|
+
let min = Infinity;
|
|
126
|
+
let max = -Infinity;
|
|
127
|
+
for (let i = 0; i < n; i++) {
|
|
128
|
+
const v = values[i] ?? 0;
|
|
129
|
+
if (v < min) min = v;
|
|
130
|
+
if (v > max) max = v;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const out = new Float32Array(n);
|
|
134
|
+
const range = max - min;
|
|
135
|
+
|
|
136
|
+
if (range === 0 || !Number.isFinite(range)) {
|
|
137
|
+
out.fill(0.5);
|
|
138
|
+
return out;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
for (let i = 0; i < n; i++) {
|
|
142
|
+
out[i] = ((values[i] ?? 0) - min) / range;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Compute beat salience signal from mel spectrogram.
|
|
150
|
+
*
|
|
151
|
+
* This is an intermediate signal suitable for peak picking to extract
|
|
152
|
+
* beat candidates. It combines onset envelope with additional smoothing
|
|
153
|
+
* tuned for beat-like (rather than onset-like) detection.
|
|
154
|
+
*/
|
|
155
|
+
export function beatSalienceFromMel(
|
|
156
|
+
mel: MelSpectrogram,
|
|
157
|
+
spec: Spectrogram,
|
|
158
|
+
options?: { smoothMs?: number }
|
|
159
|
+
): BeatSalienceSignal {
|
|
160
|
+
const smoothMs = options?.smoothMs ?? 50;
|
|
161
|
+
|
|
162
|
+
// Compute onset envelope with more smoothing than default onset detection.
|
|
163
|
+
// We want to capture the "attack envelope" of beats, not individual onsets.
|
|
164
|
+
const onset = onsetEnvelopeFromMel(mel, {
|
|
165
|
+
smoothMs: smoothMs,
|
|
166
|
+
diffMethod: "rectified",
|
|
167
|
+
useLog: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Compute spectral flux from the spectrogram.
|
|
171
|
+
const flux = spectralFlux(spec);
|
|
172
|
+
|
|
173
|
+
// Ensure times align (they should, but be defensive).
|
|
174
|
+
const n = Math.min(onset.times.length, flux.length);
|
|
175
|
+
|
|
176
|
+
// Z-score normalize both signals for equal contribution.
|
|
177
|
+
const onsetNorm = zScoreNormalize(onset.values.subarray(0, n));
|
|
178
|
+
const fluxNorm = zScoreNormalize(flux.subarray(0, n));
|
|
179
|
+
|
|
180
|
+
// Combine: weighted sum favoring onset envelope (it's more beat-specific).
|
|
181
|
+
const combined = new Float32Array(n);
|
|
182
|
+
const onsetWeight = 0.7;
|
|
183
|
+
const fluxWeight = 0.3;
|
|
184
|
+
|
|
185
|
+
for (let i = 0; i < n; i++) {
|
|
186
|
+
combined[i] = onsetWeight * (onsetNorm[i] ?? 0) + fluxWeight * (fluxNorm[i] ?? 0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Apply final smoothing to reduce micro-peaks.
|
|
190
|
+
const dt = n >= 2 ? ((onset.times[1] ?? 0) - (onset.times[0] ?? 0)) : 0.01;
|
|
191
|
+
const windowFrames = Math.max(1, Math.round((smoothMs / 1000) / Math.max(1e-9, dt)));
|
|
192
|
+
const smoothed = movingAverage(combined, windowFrames | 1);
|
|
193
|
+
|
|
194
|
+
// Normalize to [0, 1] for consistent interpretation.
|
|
195
|
+
const normalized = minMaxNormalize(smoothed);
|
|
196
|
+
|
|
197
|
+
return {
|
|
198
|
+
times: onset.times.subarray(0, n),
|
|
199
|
+
values: normalized,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Pick peaks from the salience signal to extract beat candidates.
|
|
205
|
+
*
|
|
206
|
+
* Uses relaxed parameters to err on the side of too many candidates.
|
|
207
|
+
* The goal is coverage, not precision.
|
|
208
|
+
*/
|
|
209
|
+
function pickBeatCandidates(
|
|
210
|
+
salience: BeatSalienceSignal,
|
|
211
|
+
options: BeatCandidatesOptions,
|
|
212
|
+
source: BeatCandidateSource
|
|
213
|
+
): BeatCandidate[] {
|
|
214
|
+
const minIntervalSec = options.minIntervalSec ?? 0.1;
|
|
215
|
+
const thresholdFactor = options.thresholdFactor ?? 0.5;
|
|
216
|
+
|
|
217
|
+
const { times, values } = salience;
|
|
218
|
+
const n = values.length;
|
|
219
|
+
|
|
220
|
+
if (n < 3) return [];
|
|
221
|
+
|
|
222
|
+
// Compute adaptive threshold based on signal statistics.
|
|
223
|
+
const { mean, std } = meanStd(values);
|
|
224
|
+
// Low threshold to get dense candidates.
|
|
225
|
+
// thresholdFactor of 0.5 means: mean + 0.5*std (quite low).
|
|
226
|
+
const threshold = mean + thresholdFactor * std;
|
|
227
|
+
|
|
228
|
+
const candidates: BeatCandidate[] = [];
|
|
229
|
+
let lastPeakTime = -Infinity;
|
|
230
|
+
|
|
231
|
+
for (let i = 1; i < n - 1; i++) {
|
|
232
|
+
const v = values[i] ?? 0;
|
|
233
|
+
|
|
234
|
+
// Must be above threshold.
|
|
235
|
+
if (v < threshold) continue;
|
|
236
|
+
|
|
237
|
+
// Must be a local maximum.
|
|
238
|
+
const prev = values[i - 1] ?? 0;
|
|
239
|
+
const next = values[i + 1] ?? 0;
|
|
240
|
+
if (!(v > prev && v > next)) continue;
|
|
241
|
+
|
|
242
|
+
const t = times[i] ?? 0;
|
|
243
|
+
|
|
244
|
+
// Enforce minimum interval.
|
|
245
|
+
if (t - lastPeakTime < minIntervalSec) {
|
|
246
|
+
// If within interval, keep the stronger peak.
|
|
247
|
+
const last = candidates[candidates.length - 1];
|
|
248
|
+
if (last && v > last.strength) {
|
|
249
|
+
last.time = t;
|
|
250
|
+
last.strength = v;
|
|
251
|
+
}
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
candidates.push({
|
|
256
|
+
time: t,
|
|
257
|
+
strength: v,
|
|
258
|
+
source,
|
|
259
|
+
});
|
|
260
|
+
lastPeakTime = t;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return candidates;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Detect beat candidates from mel spectrogram and spectrogram.
|
|
268
|
+
*
|
|
269
|
+
* This is the main entry point for beat candidate detection.
|
|
270
|
+
*
|
|
271
|
+
* Design principles:
|
|
272
|
+
* - Dense candidates (err on side of too many)
|
|
273
|
+
* - No BPM inference
|
|
274
|
+
* - No grid assumptions
|
|
275
|
+
* - Whole-track normalization for consistency
|
|
276
|
+
* - Deterministic (same input -> same output)
|
|
277
|
+
*/
|
|
278
|
+
export function detectBeatCandidates(
|
|
279
|
+
mel: MelSpectrogram,
|
|
280
|
+
spec: Spectrogram,
|
|
281
|
+
options?: BeatCandidatesOptions
|
|
282
|
+
): BeatCandidatesOutput {
|
|
283
|
+
const opts: BeatCandidatesOptions = {
|
|
284
|
+
minIntervalSec: options?.minIntervalSec ?? 0.1,
|
|
285
|
+
thresholdFactor: options?.thresholdFactor ?? 0.5,
|
|
286
|
+
smoothMs: options?.smoothMs ?? 50,
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
// Compute beat salience signal.
|
|
290
|
+
const salience = beatSalienceFromMel(mel, spec, { smoothMs: opts.smoothMs });
|
|
291
|
+
|
|
292
|
+
// Pick peaks from salience.
|
|
293
|
+
const candidates = pickBeatCandidates(salience, opts, "combined");
|
|
294
|
+
|
|
295
|
+
return {
|
|
296
|
+
candidates,
|
|
297
|
+
salience,
|
|
298
|
+
};
|
|
299
|
+
}
|
package/src/dsp/cqt.ts
ADDED
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Constant-Q Transform (CQT) implementation for F5.
|
|
3
|
+
*
|
|
4
|
+
* CQT provides log-frequency resolution aligned to musical pitch ratios.
|
|
5
|
+
* This implementation builds on the existing STFT infrastructure by:
|
|
6
|
+
* 1. Computing an STFT using the existing spectrogram function
|
|
7
|
+
* 2. Applying a CQT filterbank in the frequency domain
|
|
8
|
+
*
|
|
9
|
+
* The CQT is an internal spectral view, not a user-facing spectrogram.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { CqtConfig, CqtSpectrogram, MirRunMeta } from "../types";
|
|
13
|
+
import { spectrogram, type AudioBufferLike } from "./spectrogram";
|
|
14
|
+
|
|
15
|
+
// ----------------------------
|
|
16
|
+
// Default Configuration
|
|
17
|
+
// ----------------------------
|
|
18
|
+
|
|
19
|
+
/** Default CQT configuration values */
|
|
20
|
+
export const CQT_DEFAULTS = {
|
|
21
|
+
/** Quarter-tone resolution (24 bins per octave) */
|
|
22
|
+
binsPerOctave: 24,
|
|
23
|
+
/** C1 (lowest note on a standard piano) */
|
|
24
|
+
fMin: 32.7,
|
|
25
|
+
/** C9 (well above audible range for most content) */
|
|
26
|
+
fMax: 8372,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
// ----------------------------
|
|
30
|
+
// Utility Functions
|
|
31
|
+
// ----------------------------
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Convert CQT bin index to frequency in Hz.
|
|
35
|
+
*/
|
|
36
|
+
export function cqtBinToHz(bin: number, config: CqtConfig): number {
|
|
37
|
+
return config.fMin * Math.pow(2, bin / config.binsPerOctave);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Convert frequency in Hz to CQT bin index (may be fractional).
|
|
42
|
+
*/
|
|
43
|
+
export function hzToCqtBin(hz: number, config: CqtConfig): number {
|
|
44
|
+
if (hz <= 0) return -Infinity;
|
|
45
|
+
return config.binsPerOctave * Math.log2(hz / config.fMin);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Calculate number of octaves covered by the CQT config.
|
|
50
|
+
*/
|
|
51
|
+
export function getNumOctaves(config: CqtConfig): number {
|
|
52
|
+
return Math.log2(config.fMax / config.fMin);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Calculate total number of CQT bins.
|
|
57
|
+
*/
|
|
58
|
+
export function getNumBins(config: CqtConfig): number {
|
|
59
|
+
const nOctaves = getNumOctaves(config);
|
|
60
|
+
return Math.ceil(nOctaves * config.binsPerOctave);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Generate the center frequencies for all CQT bins.
|
|
65
|
+
*/
|
|
66
|
+
export function getCqtBinFrequencies(config: CqtConfig): Float32Array {
|
|
67
|
+
const nBins = getNumBins(config);
|
|
68
|
+
const freqs = new Float32Array(nBins);
|
|
69
|
+
for (let k = 0; k < nBins; k++) {
|
|
70
|
+
freqs[k] = cqtBinToHz(k, config);
|
|
71
|
+
}
|
|
72
|
+
return freqs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ----------------------------
|
|
76
|
+
// CQT Kernel Bank
|
|
77
|
+
// ----------------------------
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A sparse CQT kernel for a single frequency bin.
|
|
81
|
+
* Each kernel maps a range of STFT bins to a single CQT bin.
|
|
82
|
+
*/
|
|
83
|
+
type CqtKernel = {
|
|
84
|
+
/** Center frequency of this CQT bin in Hz */
|
|
85
|
+
centerFreq: number;
|
|
86
|
+
/** Starting STFT bin index (inclusive) */
|
|
87
|
+
startBin: number;
|
|
88
|
+
/** Ending STFT bin index (exclusive) */
|
|
89
|
+
endBin: number;
|
|
90
|
+
/** Weights for each STFT bin in the range [startBin, endBin) */
|
|
91
|
+
weights: Float32Array;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pre-computed CQT kernel bank for efficient application to STFT frames.
|
|
96
|
+
*/
|
|
97
|
+
type CqtKernelBank = {
|
|
98
|
+
config: CqtConfig;
|
|
99
|
+
fftSize: number;
|
|
100
|
+
sampleRate: number;
|
|
101
|
+
kernels: CqtKernel[];
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** Cache for kernel banks to avoid recomputation */
|
|
105
|
+
const kernelBankCache = new Map<string, CqtKernelBank>();
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Generate a cache key for the kernel bank.
|
|
109
|
+
*/
|
|
110
|
+
function kernelCacheKey(config: CqtConfig, fftSize: number, sampleRate: number): string {
|
|
111
|
+
return `${config.binsPerOctave}:${config.fMin}:${config.fMax}:${fftSize}:${sampleRate}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Create a triangular window centered at a frequency with log-spaced bandwidth.
|
|
116
|
+
* This creates a simple triangular filterbank similar to mel filterbanks.
|
|
117
|
+
*/
|
|
118
|
+
function createCqtKernel(
|
|
119
|
+
binIndex: number,
|
|
120
|
+
config: CqtConfig,
|
|
121
|
+
fftSize: number,
|
|
122
|
+
sampleRate: number
|
|
123
|
+
): CqtKernel {
|
|
124
|
+
const centerFreq = cqtBinToHz(binIndex, config);
|
|
125
|
+
const freqResolution = sampleRate / fftSize;
|
|
126
|
+
|
|
127
|
+
// Q factor: ratio of center frequency to bandwidth
|
|
128
|
+
// For CQT, Q is constant, which gives logarithmic frequency resolution
|
|
129
|
+
// Q = f / Δf = 1 / (2^(1/binsPerOctave) - 1)
|
|
130
|
+
const Q = 1 / (Math.pow(2, 1 / config.binsPerOctave) - 1);
|
|
131
|
+
|
|
132
|
+
// Bandwidth for this bin
|
|
133
|
+
const bandwidth = centerFreq / Q;
|
|
134
|
+
|
|
135
|
+
// Lower and upper edge frequencies
|
|
136
|
+
const fLow = centerFreq - bandwidth / 2;
|
|
137
|
+
const fHigh = centerFreq + bandwidth / 2;
|
|
138
|
+
|
|
139
|
+
// Convert to STFT bin indices
|
|
140
|
+
const startBin = Math.max(0, Math.floor(fLow / freqResolution));
|
|
141
|
+
const endBin = Math.min(
|
|
142
|
+
Math.floor(fftSize / 2) + 1,
|
|
143
|
+
Math.ceil(fHigh / freqResolution) + 1
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const numBins = Math.max(1, endBin - startBin);
|
|
147
|
+
const weights = new Float32Array(numBins);
|
|
148
|
+
|
|
149
|
+
// Create triangular window weights
|
|
150
|
+
for (let i = 0; i < numBins; i++) {
|
|
151
|
+
const binFreq = (startBin + i) * freqResolution;
|
|
152
|
+
|
|
153
|
+
if (binFreq <= centerFreq) {
|
|
154
|
+
// Rising edge
|
|
155
|
+
if (centerFreq > fLow) {
|
|
156
|
+
weights[i] = (binFreq - fLow) / (centerFreq - fLow);
|
|
157
|
+
} else {
|
|
158
|
+
weights[i] = 1;
|
|
159
|
+
}
|
|
160
|
+
} else {
|
|
161
|
+
// Falling edge
|
|
162
|
+
if (fHigh > centerFreq) {
|
|
163
|
+
weights[i] = (fHigh - binFreq) / (fHigh - centerFreq);
|
|
164
|
+
} else {
|
|
165
|
+
weights[i] = 1;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Clamp to valid range
|
|
170
|
+
weights[i] = Math.max(0, Math.min(1, weights[i] ?? 0));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Normalize weights so they sum to 1
|
|
174
|
+
let sum = 0;
|
|
175
|
+
for (let i = 0; i < numBins; i++) {
|
|
176
|
+
sum += weights[i] ?? 0;
|
|
177
|
+
}
|
|
178
|
+
if (sum > 0) {
|
|
179
|
+
for (let i = 0; i < numBins; i++) {
|
|
180
|
+
weights[i] = (weights[i] ?? 0) / sum;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
centerFreq,
|
|
186
|
+
startBin,
|
|
187
|
+
endBin,
|
|
188
|
+
weights,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create or retrieve a cached CQT kernel bank.
|
|
194
|
+
*/
|
|
195
|
+
function getCqtKernelBank(
|
|
196
|
+
config: CqtConfig,
|
|
197
|
+
fftSize: number,
|
|
198
|
+
sampleRate: number
|
|
199
|
+
): CqtKernelBank {
|
|
200
|
+
const key = kernelCacheKey(config, fftSize, sampleRate);
|
|
201
|
+
const cached = kernelBankCache.get(key);
|
|
202
|
+
if (cached) return cached;
|
|
203
|
+
|
|
204
|
+
const nBins = getNumBins(config);
|
|
205
|
+
const kernels: CqtKernel[] = new Array(nBins);
|
|
206
|
+
|
|
207
|
+
for (let k = 0; k < nBins; k++) {
|
|
208
|
+
kernels[k] = createCqtKernel(k, config, fftSize, sampleRate);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const bank: CqtKernelBank = {
|
|
212
|
+
config,
|
|
213
|
+
fftSize,
|
|
214
|
+
sampleRate,
|
|
215
|
+
kernels,
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
kernelBankCache.set(key, bank);
|
|
219
|
+
return bank;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Apply CQT kernel bank to an STFT magnitude frame.
|
|
224
|
+
*/
|
|
225
|
+
function applyCqtKernels(
|
|
226
|
+
stftMagnitudes: Float32Array,
|
|
227
|
+
kernelBank: CqtKernelBank
|
|
228
|
+
): Float32Array {
|
|
229
|
+
const nCqtBins = kernelBank.kernels.length;
|
|
230
|
+
const cqtMagnitudes = new Float32Array(nCqtBins);
|
|
231
|
+
|
|
232
|
+
for (let k = 0; k < nCqtBins; k++) {
|
|
233
|
+
const kernel = kernelBank.kernels[k];
|
|
234
|
+
if (!kernel) continue;
|
|
235
|
+
|
|
236
|
+
let sum = 0;
|
|
237
|
+
for (let i = 0; i < kernel.weights.length; i++) {
|
|
238
|
+
const stftBin = kernel.startBin + i;
|
|
239
|
+
const stftMag = stftMagnitudes[stftBin] ?? 0;
|
|
240
|
+
const weight = kernel.weights[i] ?? 0;
|
|
241
|
+
sum += stftMag * weight;
|
|
242
|
+
}
|
|
243
|
+
cqtMagnitudes[k] = sum;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return cqtMagnitudes;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ----------------------------
|
|
250
|
+
// Main CQT Function
|
|
251
|
+
// ----------------------------
|
|
252
|
+
|
|
253
|
+
export type CqtOptions = {
|
|
254
|
+
/** Optional cancellation hook; checked periodically. */
|
|
255
|
+
isCancelled?: () => boolean;
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Apply default values to a partial CQT config.
|
|
260
|
+
*/
|
|
261
|
+
export function withCqtDefaults(partial?: Partial<CqtConfig>): CqtConfig {
|
|
262
|
+
return {
|
|
263
|
+
binsPerOctave: partial?.binsPerOctave ?? CQT_DEFAULTS.binsPerOctave,
|
|
264
|
+
fMin: partial?.fMin ?? CQT_DEFAULTS.fMin,
|
|
265
|
+
fMax: partial?.fMax ?? CQT_DEFAULTS.fMax,
|
|
266
|
+
hopSize: partial?.hopSize,
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Compute a CQT spectrogram from audio.
|
|
272
|
+
*
|
|
273
|
+
* This function:
|
|
274
|
+
* 1. Computes an STFT using the existing spectrogram infrastructure
|
|
275
|
+
* 2. Applies a CQT filterbank to each STFT frame
|
|
276
|
+
* 3. Returns a log-frequency representation
|
|
277
|
+
*
|
|
278
|
+
* @param audio - Audio buffer to analyze
|
|
279
|
+
* @param config - CQT configuration
|
|
280
|
+
* @param options - Optional processing options
|
|
281
|
+
* @returns CQT spectrogram with log-frequency resolution
|
|
282
|
+
*/
|
|
283
|
+
export async function cqtSpectrogram(
|
|
284
|
+
audio: AudioBufferLike,
|
|
285
|
+
config: CqtConfig,
|
|
286
|
+
options: CqtOptions = {}
|
|
287
|
+
): Promise<CqtSpectrogram> {
|
|
288
|
+
const sampleRate = audio.sampleRate;
|
|
289
|
+
|
|
290
|
+
// Validate config
|
|
291
|
+
if (config.fMin <= 0) {
|
|
292
|
+
throw new Error("@octoseq/mir: CQT fMin must be positive");
|
|
293
|
+
}
|
|
294
|
+
if (config.fMax <= config.fMin) {
|
|
295
|
+
throw new Error("@octoseq/mir: CQT fMax must be greater than fMin");
|
|
296
|
+
}
|
|
297
|
+
if (config.binsPerOctave <= 0) {
|
|
298
|
+
throw new Error("@octoseq/mir: CQT binsPerOctave must be positive");
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Determine FFT size based on lowest frequency
|
|
302
|
+
// We need sufficient frequency resolution for the lowest CQT bins
|
|
303
|
+
// Rule of thumb: fftSize should give frequency resolution of fMin / Q
|
|
304
|
+
const Q = 1 / (Math.pow(2, 1 / config.binsPerOctave) - 1);
|
|
305
|
+
const minFreqResolution = config.fMin / Q / 2; // Nyquist for the lowest bin
|
|
306
|
+
const minFftSize = Math.ceil(sampleRate / minFreqResolution);
|
|
307
|
+
|
|
308
|
+
// Round up to next power of 2
|
|
309
|
+
let fftSize = 1;
|
|
310
|
+
while (fftSize < minFftSize) {
|
|
311
|
+
fftSize *= 2;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Cap at reasonable maximum to avoid memory issues
|
|
315
|
+
fftSize = Math.min(fftSize, 16384);
|
|
316
|
+
|
|
317
|
+
// Hop size: default to 1/4 of FFT size for good time resolution
|
|
318
|
+
const hopSize = config.hopSize ?? Math.floor(fftSize / 4);
|
|
319
|
+
|
|
320
|
+
// Compute STFT
|
|
321
|
+
const stft = await spectrogram(
|
|
322
|
+
audio,
|
|
323
|
+
{ fftSize, hopSize, window: "hann" },
|
|
324
|
+
undefined,
|
|
325
|
+
{ isCancelled: options.isCancelled }
|
|
326
|
+
);
|
|
327
|
+
|
|
328
|
+
// Create or get cached kernel bank
|
|
329
|
+
const kernelBank = getCqtKernelBank(config, fftSize, sampleRate);
|
|
330
|
+
|
|
331
|
+
// Apply CQT kernels to each STFT frame
|
|
332
|
+
const nFrames = stft.magnitudes.length;
|
|
333
|
+
const cqtMagnitudes: Float32Array[] = new Array(nFrames);
|
|
334
|
+
|
|
335
|
+
for (let frame = 0; frame < nFrames; frame++) {
|
|
336
|
+
if (options.isCancelled?.()) {
|
|
337
|
+
throw new Error("@octoseq/mir: cancelled");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const stftFrame = stft.magnitudes[frame];
|
|
341
|
+
if (!stftFrame) continue;
|
|
342
|
+
|
|
343
|
+
cqtMagnitudes[frame] = applyCqtKernels(stftFrame, kernelBank);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const nOctaves = getNumOctaves(config);
|
|
347
|
+
const nBins = getNumBins(config);
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
sampleRate,
|
|
351
|
+
config,
|
|
352
|
+
times: stft.times,
|
|
353
|
+
magnitudes: cqtMagnitudes,
|
|
354
|
+
nOctaves,
|
|
355
|
+
binsPerOctave: config.binsPerOctave,
|
|
356
|
+
binFrequencies: getCqtBinFrequencies(config),
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Compute CQT and return with metadata for MIR pipeline.
|
|
362
|
+
*/
|
|
363
|
+
export async function computeCqt(
|
|
364
|
+
audio: AudioBufferLike,
|
|
365
|
+
config?: Partial<CqtConfig>,
|
|
366
|
+
options: CqtOptions = {}
|
|
367
|
+
): Promise<{ cqt: CqtSpectrogram; meta: MirRunMeta }> {
|
|
368
|
+
const startTime = performance.now();
|
|
369
|
+
|
|
370
|
+
const fullConfig = withCqtDefaults(config);
|
|
371
|
+
const cqt = await cqtSpectrogram(audio, fullConfig, options);
|
|
372
|
+
|
|
373
|
+
const endTime = performance.now();
|
|
374
|
+
|
|
375
|
+
return {
|
|
376
|
+
cqt,
|
|
377
|
+
meta: {
|
|
378
|
+
backend: "cpu",
|
|
379
|
+
usedGpu: false,
|
|
380
|
+
timings: {
|
|
381
|
+
totalMs: endTime - startTime,
|
|
382
|
+
cpuMs: endTime - startTime,
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
}
|