@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,524 @@
1
+ /**
2
+ * Band-Scoped MIR utilities for F3.
3
+ *
4
+ * These functions compute MIR features (amplitude envelope, onset strength,
5
+ * spectral flux) for frequency bands by applying spectral masks to an
6
+ * existing spectrogram.
7
+ */
8
+
9
+ import type { Spectrogram } from "./spectrogram";
10
+ import type {
11
+ FrequencyBand,
12
+ MirRunMeta,
13
+ MirRunTimings,
14
+ BandMirFunctionId,
15
+ BandMirDiagnostics,
16
+ BandMir1DResult,
17
+ } from "../types";
18
+ import {
19
+ applyBandMaskToSpectrogram,
20
+ computeFrameAmplitude,
21
+ type BandMaskOptions,
22
+ } from "./bandMask";
23
+
24
+ // Re-export types for convenience
25
+ export type { BandMirFunctionId, BandMirDiagnostics, BandMir1DResult };
26
+
27
+ export type BandMirOptions = {
28
+ /** Soft edge width in Hz for mask transitions. Default: 0 */
29
+ edgeSmoothHz?: number;
30
+ /** Options for onset strength computation */
31
+ onset?: {
32
+ /** If true, log-compress magnitudes before differencing. */
33
+ useLog?: boolean;
34
+ /** Moving-average smoothing window length in milliseconds. 0 disables smoothing. */
35
+ smoothMs?: number;
36
+ /** How to convert temporal differences into novelty. */
37
+ diffMethod?: "rectified" | "abs";
38
+ };
39
+ /** Optional cancellation hook */
40
+ isCancelled?: () => boolean;
41
+ };
42
+
43
+ // ----------------------------
44
+ // Internal Helpers
45
+ // ----------------------------
46
+
47
+ function computeDiagnostics(
48
+ energyRetainedPerFrame: Float32Array
49
+ ): BandMirDiagnostics {
50
+ const totalFrames = energyRetainedPerFrame.length;
51
+ let sum = 0;
52
+ let weakCount = 0;
53
+ let emptyCount = 0;
54
+
55
+ for (let i = 0; i < totalFrames; i++) {
56
+ const e = energyRetainedPerFrame[i] ?? 0;
57
+ sum += e;
58
+ if (e < 0.01) weakCount++;
59
+ if (e === 0) emptyCount++;
60
+ }
61
+
62
+ const meanEnergyRetained = totalFrames > 0 ? sum / totalFrames : 0;
63
+ const warnings: string[] = [];
64
+
65
+ if (meanEnergyRetained < 0.01) {
66
+ warnings.push("Band contains very little energy - check frequency range");
67
+ }
68
+ if (emptyCount > totalFrames * 0.5) {
69
+ warnings.push("More than half of frames are empty - band may not be active for this audio");
70
+ }
71
+
72
+ return {
73
+ meanEnergyRetained,
74
+ weakFrameCount: weakCount,
75
+ emptyFrameCount: emptyCount,
76
+ totalFrames,
77
+ warnings,
78
+ };
79
+ }
80
+
81
+ function createMeta(startMs: number): MirRunMeta {
82
+ const endMs = typeof performance !== "undefined" ? performance.now() : Date.now();
83
+ const timings: MirRunTimings = {
84
+ totalMs: endMs - startMs,
85
+ cpuMs: endMs - startMs,
86
+ gpuMs: 0,
87
+ };
88
+ return {
89
+ backend: "cpu",
90
+ usedGpu: false,
91
+ timings,
92
+ };
93
+ }
94
+
95
+ function movingAverage(values: Float32Array, windowFrames: number): Float32Array {
96
+ if (windowFrames <= 1) return values;
97
+
98
+ const n = values.length;
99
+ const out = new Float32Array(n);
100
+
101
+ // Centered window.
102
+ const half = Math.floor(windowFrames / 2);
103
+
104
+ // Prefix sums for stable, bug-free O(n) moving average.
105
+ const prefix = new Float64Array(n + 1);
106
+ prefix[0] = 0;
107
+ for (let i = 0; i < n; i++) {
108
+ prefix[i + 1] = (prefix[i] ?? 0) + (values[i] ?? 0);
109
+ }
110
+
111
+ for (let i = 0; i < n; i++) {
112
+ const start = Math.max(0, i - half);
113
+ const end = Math.min(n, i + half + 1);
114
+ const sum = (prefix[end] ?? 0) - (prefix[start] ?? 0);
115
+ const count = Math.max(1, end - start);
116
+ out[i] = sum / count;
117
+ }
118
+
119
+ return out;
120
+ }
121
+
122
+ function logCompress(x: number): number {
123
+ // Stable compression without -Inf.
124
+ return Math.log1p(Math.max(0, x));
125
+ }
126
+
127
+ // ----------------------------
128
+ // Band MIR Functions
129
+ // ----------------------------
130
+
131
+ /**
132
+ * Compute amplitude envelope for a frequency band.
133
+ *
134
+ * Returns the sum of magnitudes per frame within the band's frequency range.
135
+ *
136
+ * @param spec - Source spectrogram
137
+ * @param band - Frequency band to analyze
138
+ * @param options - Computation options
139
+ * @returns Band MIR result with amplitude envelope
140
+ */
141
+ export function bandAmplitudeEnvelope(
142
+ spec: Spectrogram,
143
+ band: FrequencyBand,
144
+ options?: BandMirOptions
145
+ ): BandMir1DResult {
146
+ const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
147
+
148
+ // Apply band mask
149
+ const masked = applyBandMaskToSpectrogram(spec, band, {
150
+ edgeSmoothHz: options?.edgeSmoothHz,
151
+ });
152
+
153
+ const nFrames = masked.times.length;
154
+ const values = new Float32Array(nFrames);
155
+
156
+ for (let t = 0; t < nFrames; t++) {
157
+ const mags = masked.magnitudes[t];
158
+ if (mags) {
159
+ values[t] = computeFrameAmplitude(mags);
160
+ }
161
+ }
162
+
163
+ return {
164
+ kind: "bandMir1d",
165
+ bandId: band.id,
166
+ bandLabel: band.label,
167
+ fn: "bandAmplitudeEnvelope",
168
+ times: masked.times,
169
+ values,
170
+ meta: createMeta(startMs),
171
+ diagnostics: computeDiagnostics(masked.energyRetainedPerFrame),
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Compute onset strength for a frequency band.
177
+ *
178
+ * Uses temporal differences of band-masked magnitudes.
179
+ * Adapted from onset.ts:onsetEnvelopeFromSpectrogram.
180
+ *
181
+ * @param spec - Source spectrogram
182
+ * @param band - Frequency band to analyze
183
+ * @param options - Computation options
184
+ * @returns Band MIR result with onset strength
185
+ */
186
+ export function bandOnsetStrength(
187
+ spec: Spectrogram,
188
+ band: FrequencyBand,
189
+ options?: BandMirOptions
190
+ ): BandMir1DResult {
191
+ const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
192
+
193
+ // Default onset options
194
+ const useLog = options?.onset?.useLog ?? false;
195
+ const smoothMs = options?.onset?.smoothMs ?? 30;
196
+ const diffMethod = options?.onset?.diffMethod ?? "rectified";
197
+
198
+ // Apply band mask
199
+ const masked = applyBandMaskToSpectrogram(spec, band, {
200
+ edgeSmoothHz: options?.edgeSmoothHz,
201
+ });
202
+
203
+ const nFrames = masked.times.length;
204
+ const nBins = (masked.fftSize >>> 1) + 1;
205
+ const out = new Float32Array(nFrames);
206
+
207
+ // First frame has no previous frame
208
+ out[0] = 0;
209
+
210
+ for (let t = 1; t < nFrames; t++) {
211
+ const cur = masked.magnitudes[t];
212
+ const prev = masked.magnitudes[t - 1];
213
+
214
+ if (!cur || !prev) {
215
+ out[t] = 0;
216
+ continue;
217
+ }
218
+
219
+ let sum = 0;
220
+ let binsWithData = 0;
221
+
222
+ for (let k = 0; k < nBins; k++) {
223
+ let a = cur[k] ?? 0;
224
+ let b = prev[k] ?? 0;
225
+
226
+ // Only count bins that have some energy
227
+ if (a > 0 || b > 0) {
228
+ binsWithData++;
229
+
230
+ if (useLog) {
231
+ a = logCompress(a);
232
+ b = logCompress(b);
233
+ }
234
+
235
+ const d = a - b;
236
+ sum += diffMethod === "abs" ? Math.abs(d) : Math.max(0, d);
237
+ }
238
+ }
239
+
240
+ // Normalize by number of active bins to avoid scale depending on band width
241
+ out[t] = binsWithData > 0 ? sum / binsWithData : 0;
242
+ }
243
+
244
+ // Apply smoothing if requested
245
+ const smoothMs_ = smoothMs;
246
+ if (smoothMs_ > 0 && nFrames >= 2) {
247
+ const dt = (masked.times[1] ?? 0) - (masked.times[0] ?? 0);
248
+ const windowFrames = Math.max(1, Math.round((smoothMs_ / 1000) / Math.max(1e-9, dt)));
249
+ return {
250
+ kind: "bandMir1d",
251
+ bandId: band.id,
252
+ bandLabel: band.label,
253
+ fn: "bandOnsetStrength",
254
+ times: masked.times,
255
+ values: movingAverage(out, windowFrames | 1),
256
+ meta: createMeta(startMs),
257
+ diagnostics: computeDiagnostics(masked.energyRetainedPerFrame),
258
+ };
259
+ }
260
+
261
+ return {
262
+ kind: "bandMir1d",
263
+ bandId: band.id,
264
+ bandLabel: band.label,
265
+ fn: "bandOnsetStrength",
266
+ times: masked.times,
267
+ values: out,
268
+ meta: createMeta(startMs),
269
+ diagnostics: computeDiagnostics(masked.energyRetainedPerFrame),
270
+ };
271
+ }
272
+
273
+ /**
274
+ * Compute spectral flux for a frequency band.
275
+ *
276
+ * Uses L1 distance between consecutive normalized band-masked spectra.
277
+ * Adapted from spectral.ts:spectralFlux.
278
+ *
279
+ * @param spec - Source spectrogram
280
+ * @param band - Frequency band to analyze
281
+ * @param options - Computation options
282
+ * @returns Band MIR result with spectral flux
283
+ */
284
+ export function bandSpectralFlux(
285
+ spec: Spectrogram,
286
+ band: FrequencyBand,
287
+ options?: BandMirOptions
288
+ ): BandMir1DResult {
289
+ const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
290
+
291
+ // Apply band mask
292
+ const masked = applyBandMaskToSpectrogram(spec, band, {
293
+ edgeSmoothHz: options?.edgeSmoothHz,
294
+ });
295
+
296
+ const nFrames = masked.times.length;
297
+ const nBins = (masked.fftSize >>> 1) + 1;
298
+ const out = new Float32Array(nFrames);
299
+
300
+ let prev: Float32Array | null = null;
301
+
302
+ for (let t = 0; t < nFrames; t++) {
303
+ const mags = masked.magnitudes[t];
304
+
305
+ if (!mags) {
306
+ out[t] = 0;
307
+ prev = null;
308
+ continue;
309
+ }
310
+
311
+ // Normalize to reduce sensitivity to overall level
312
+ let sum = 0;
313
+ for (let k = 0; k < nBins; k++) sum += mags[k] ?? 0;
314
+
315
+ if (sum <= 0) {
316
+ out[t] = 0;
317
+ prev = null;
318
+ continue;
319
+ }
320
+
321
+ const cur = new Float32Array(nBins);
322
+ const inv = 1 / sum;
323
+ for (let k = 0; k < nBins; k++) cur[k] = (mags[k] ?? 0) * inv;
324
+
325
+ if (!prev) {
326
+ out[t] = 0;
327
+ prev = cur;
328
+ continue;
329
+ }
330
+
331
+ let flux = 0;
332
+ for (let k = 0; k < nBins; k++) {
333
+ const d = (cur[k] ?? 0) - (prev[k] ?? 0);
334
+ flux += Math.abs(d);
335
+ }
336
+
337
+ out[t] = flux;
338
+ prev = cur;
339
+ }
340
+
341
+ return {
342
+ kind: "bandMir1d",
343
+ bandId: band.id,
344
+ bandLabel: band.label,
345
+ fn: "bandSpectralFlux",
346
+ times: masked.times,
347
+ values: out,
348
+ meta: createMeta(startMs),
349
+ diagnostics: computeDiagnostics(masked.energyRetainedPerFrame),
350
+ };
351
+ }
352
+
353
+ /**
354
+ * Compute spectral centroid for a frequency band.
355
+ *
356
+ * Returns the weighted average of frequency bins within the band (center of mass).
357
+ * Output is in Hz per frame.
358
+ *
359
+ * @param spec - Source spectrogram
360
+ * @param band - Frequency band to analyze
361
+ * @param options - Computation options
362
+ * @returns Band MIR result with spectral centroid in Hz
363
+ */
364
+ export function bandSpectralCentroid(
365
+ spec: Spectrogram,
366
+ band: FrequencyBand,
367
+ options?: BandMirOptions
368
+ ): BandMir1DResult {
369
+ const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
370
+
371
+ // Apply band mask
372
+ const masked = applyBandMaskToSpectrogram(spec, band, {
373
+ edgeSmoothHz: options?.edgeSmoothHz,
374
+ });
375
+
376
+ const nFrames = masked.times.length;
377
+ const nBins = (masked.fftSize >>> 1) + 1;
378
+ const binHz = masked.sampleRate / masked.fftSize;
379
+ const out = new Float32Array(nFrames);
380
+
381
+ for (let t = 0; t < nFrames; t++) {
382
+ const mags = masked.magnitudes[t];
383
+
384
+ if (!mags) {
385
+ out[t] = 0;
386
+ continue;
387
+ }
388
+
389
+ let num = 0;
390
+ let den = 0;
391
+
392
+ // Weighted average: centroid = Σ(f * m) / Σ(m)
393
+ for (let k = 0; k < nBins; k++) {
394
+ const m = mags[k] ?? 0;
395
+ if (m > 0) {
396
+ const f = k * binHz;
397
+ num += f * m;
398
+ den += m;
399
+ }
400
+ }
401
+
402
+ out[t] = den > 0 ? num / den : 0;
403
+ }
404
+
405
+ return {
406
+ kind: "bandMir1d",
407
+ bandId: band.id,
408
+ bandLabel: band.label,
409
+ fn: "bandSpectralCentroid",
410
+ times: masked.times,
411
+ values: out,
412
+ meta: createMeta(startMs),
413
+ diagnostics: computeDiagnostics(masked.energyRetainedPerFrame),
414
+ };
415
+ }
416
+
417
+ // ----------------------------
418
+ // Batch Runner
419
+ // ----------------------------
420
+
421
+ export type BandMirBatchRequest = {
422
+ bands: FrequencyBand[];
423
+ functions: BandMirFunctionId[];
424
+ /** Maximum number of bands to process concurrently. Default: 4 */
425
+ maxConcurrent?: number;
426
+ };
427
+
428
+ export type BandMirBatchResult = {
429
+ /** Results keyed by bandId, each containing results for requested functions */
430
+ results: Map<string, BandMir1DResult[]>;
431
+ /** Total computation time in ms */
432
+ totalTimingMs: number;
433
+ };
434
+
435
+ /**
436
+ * Run band MIR analysis for multiple bands.
437
+ *
438
+ * Processes bands sequentially (web workers don't have real parallelism
439
+ * within a single thread). The maxConcurrent option is reserved for
440
+ * future multi-worker support.
441
+ *
442
+ * @param spec - Source spectrogram
443
+ * @param request - Batch request specifying bands and functions
444
+ * @param options - Computation options
445
+ * @returns Map of results by band ID
446
+ */
447
+ export async function runBandMirBatch(
448
+ spec: Spectrogram,
449
+ request: BandMirBatchRequest,
450
+ options?: BandMirOptions
451
+ ): Promise<BandMirBatchResult> {
452
+ const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
453
+
454
+ const results = new Map<string, BandMir1DResult[]>();
455
+
456
+ for (const band of request.bands) {
457
+ if (options?.isCancelled?.()) {
458
+ throw new Error("@octoseq/mir: cancelled");
459
+ }
460
+
461
+ if (!band.enabled) continue;
462
+
463
+ const bandResults: BandMir1DResult[] = [];
464
+
465
+ for (const fn of request.functions) {
466
+ if (options?.isCancelled?.()) {
467
+ throw new Error("@octoseq/mir: cancelled");
468
+ }
469
+
470
+ let result: BandMir1DResult;
471
+
472
+ switch (fn) {
473
+ case "bandAmplitudeEnvelope":
474
+ result = bandAmplitudeEnvelope(spec, band, options);
475
+ break;
476
+ case "bandOnsetStrength":
477
+ result = bandOnsetStrength(spec, band, options);
478
+ break;
479
+ case "bandSpectralFlux":
480
+ result = bandSpectralFlux(spec, band, options);
481
+ break;
482
+ case "bandSpectralCentroid":
483
+ result = bandSpectralCentroid(spec, band, options);
484
+ break;
485
+ default:
486
+ // Exhaustive check
487
+ const _exhaustive: never = fn;
488
+ throw new Error(`Unknown band MIR function: ${_exhaustive}`);
489
+ }
490
+
491
+ bandResults.push(result);
492
+ }
493
+
494
+ results.set(band.id, bandResults);
495
+ }
496
+
497
+ const endMs = typeof performance !== "undefined" ? performance.now() : Date.now();
498
+
499
+ return {
500
+ results,
501
+ totalTimingMs: endMs - startMs,
502
+ };
503
+ }
504
+
505
+ /**
506
+ * Get a human-readable label for a band MIR function.
507
+ *
508
+ * @param fn - Band MIR function ID
509
+ * @returns Human-readable label
510
+ */
511
+ export function getBandMirFunctionLabel(fn: BandMirFunctionId): string {
512
+ switch (fn) {
513
+ case "bandAmplitudeEnvelope":
514
+ return "Amplitude Envelope";
515
+ case "bandOnsetStrength":
516
+ return "Onset Strength";
517
+ case "bandSpectralFlux":
518
+ return "Spectral Flux";
519
+ case "bandSpectralCentroid":
520
+ return "Spectral Centroid";
521
+ default:
522
+ return fn;
523
+ }
524
+ }