@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,552 @@
1
+ /**
2
+ * Band Proposal Generation for F5.
3
+ *
4
+ * Generates automated suggestions for "interesting" frequency bands.
5
+ * Proposals are advisory only - they must be explicitly promoted by the user.
6
+ *
7
+ * Algorithm overview:
8
+ * 1. Analyze spectral structure using CQT
9
+ * 2. Identify regions with concentrated energy or distinct activity
10
+ * 3. Score and rank candidates
11
+ * 4. Generate proposal objects with explanations
12
+ */
13
+
14
+ import type {
15
+ BandProposal,
16
+ BandProposalConfig,
17
+ BandProposalResult,
18
+ BandProposalSource,
19
+ CqtSpectrogram,
20
+ FrequencyBand,
21
+ FrequencyBandProvenance,
22
+ MirRunMeta,
23
+ } from "../types";
24
+ import { type AudioBufferLike, type Spectrogram, spectrogram } from "./spectrogram";
25
+ import { cqtSpectrogram, cqtBinToHz, withCqtDefaults, getNumBins } from "./cqt";
26
+ import { harmonicEnergy, bassPitchMotion, tonalStability } from "./cqtSignals";
27
+
28
+ // ----------------------------
29
+ // Configuration Defaults
30
+ // ----------------------------
31
+
32
+ const PROPOSAL_DEFAULTS: Required<BandProposalConfig> = {
33
+ maxProposals: 8,
34
+ minSalience: 0.3,
35
+ minSeparationOctaves: 0.5,
36
+ minBandwidthHz: 20,
37
+ analysisWindow: 0, // 0 = full track
38
+ };
39
+
40
+ // ----------------------------
41
+ // Types
42
+ // ----------------------------
43
+
44
+ type SpectralPeak = {
45
+ binIndex: number;
46
+ centerHz: number;
47
+ magnitude: number;
48
+ bandwidth: number; // in octaves
49
+ lowHz: number;
50
+ highHz: number;
51
+ };
52
+
53
+ type ProposalCandidate = {
54
+ peak: SpectralPeak;
55
+ salience: number;
56
+ source: BandProposalSource;
57
+ reason: string;
58
+ };
59
+
60
+ // ----------------------------
61
+ // Utility Functions
62
+ // ----------------------------
63
+
64
+ /**
65
+ * Generate a unique proposal ID.
66
+ */
67
+ function generateProposalId(): string {
68
+ return `proposal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
69
+ }
70
+
71
+ /**
72
+ * Generate a band ID for the proposed band.
73
+ */
74
+ function generateBandId(): string {
75
+ return `band-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
76
+ }
77
+
78
+ /**
79
+ * Compute time-averaged magnitude spectrum from CQT.
80
+ */
81
+ function computeAverageCqtSpectrum(cqt: CqtSpectrogram): Float32Array {
82
+ const nBins = cqt.magnitudes[0]?.length ?? 0;
83
+ const nFrames = cqt.magnitudes.length;
84
+ const average = new Float32Array(nBins);
85
+
86
+ for (let frame = 0; frame < nFrames; frame++) {
87
+ const frameMags = cqt.magnitudes[frame];
88
+ if (!frameMags) continue;
89
+
90
+ for (let bin = 0; bin < nBins; bin++) {
91
+ average[bin] = (average[bin] ?? 0) + (frameMags[bin] ?? 0);
92
+ }
93
+ }
94
+
95
+ for (let bin = 0; bin < nBins; bin++) {
96
+ average[bin] = (average[bin] ?? 0) / nFrames;
97
+ }
98
+
99
+ return average;
100
+ }
101
+
102
+ /**
103
+ * Find local maxima in a 1D array.
104
+ */
105
+ function findLocalMaxima(
106
+ values: Float32Array,
107
+ minNeighborDistance: number = 3
108
+ ): number[] {
109
+ const peaks: number[] = [];
110
+
111
+ for (let i = minNeighborDistance; i < values.length - minNeighborDistance; i++) {
112
+ let isPeak = true;
113
+ const centerVal = values[i] ?? 0;
114
+
115
+ for (let j = -minNeighborDistance; j <= minNeighborDistance; j++) {
116
+ if (j === 0) continue;
117
+ if ((values[i + j] ?? 0) >= centerVal) {
118
+ isPeak = false;
119
+ break;
120
+ }
121
+ }
122
+
123
+ if (isPeak && centerVal > 0) {
124
+ peaks.push(i);
125
+ }
126
+ }
127
+
128
+ return peaks;
129
+ }
130
+
131
+ /**
132
+ * Compute the bandwidth (in bins) at -3dB around a peak.
133
+ */
134
+ function computeBandwidth(
135
+ values: Float32Array,
136
+ peakBin: number,
137
+ cqt: CqtSpectrogram,
138
+ minBandwidthHz: number
139
+ ): { lowBin: number; highBin: number; lowHz: number; highHz: number; bandwidthOctaves: number } {
140
+ const peakMag = values[peakBin] ?? 0;
141
+ const threshold = peakMag * 0.707; // -3dB
142
+
143
+ // Find lower edge
144
+ let lowBin = peakBin;
145
+ while (lowBin > 0 && (values[lowBin - 1] ?? 0) >= threshold) {
146
+ lowBin--;
147
+ }
148
+
149
+ // Find upper edge
150
+ let highBin = peakBin;
151
+ while (highBin < values.length - 1 && (values[highBin + 1] ?? 0) >= threshold) {
152
+ highBin++;
153
+ }
154
+
155
+ // Enforce a minimum bandwidth in Hz to avoid implausibly narrow bands (e.g. ~1 Hz at low freqs).
156
+ // Expand symmetrically around the peak when possible, otherwise expand toward the available side.
157
+ if (minBandwidthHz > 0) {
158
+ // Avoid infinite loops if something goes wrong
159
+ const maxExpansions = values.length;
160
+ for (let i = 0; i < maxExpansions; i++) {
161
+ const lowHzTmp = cqtBinToHz(lowBin, cqt.config);
162
+ const highHzTmp = cqtBinToHz(highBin, cqt.config);
163
+ if (highHzTmp - lowHzTmp >= minBandwidthHz) break;
164
+
165
+ const canExpandLow = lowBin > 0;
166
+ const canExpandHigh = highBin < values.length - 1;
167
+ if (!canExpandLow && !canExpandHigh) break;
168
+
169
+ if (canExpandLow && canExpandHigh) {
170
+ lowBin--;
171
+ highBin++;
172
+ } else if (canExpandLow) {
173
+ lowBin--;
174
+ } else {
175
+ highBin++;
176
+ }
177
+ }
178
+ }
179
+
180
+ const lowHz = cqtBinToHz(lowBin, cqt.config);
181
+ const highHz = cqtBinToHz(highBin, cqt.config);
182
+ const bandwidthOctaves = Math.log2(highHz / lowHz);
183
+
184
+ return { lowBin, highBin, lowHz, highHz, bandwidthOctaves };
185
+ }
186
+
187
+ /**
188
+ * Compute temporal variance of energy in a frequency band.
189
+ * Low variance = consistent energy; high variance = transient or intermittent.
190
+ */
191
+ function computeTemporalVariance(
192
+ cqt: CqtSpectrogram,
193
+ lowBin: number,
194
+ highBin: number
195
+ ): number {
196
+ const nFrames = cqt.magnitudes.length;
197
+ const bandEnergies = new Float32Array(nFrames);
198
+
199
+ // Compute energy in band for each frame
200
+ for (let frame = 0; frame < nFrames; frame++) {
201
+ const frameMags = cqt.magnitudes[frame];
202
+ if (!frameMags) continue;
203
+
204
+ let energy = 0;
205
+ for (let bin = lowBin; bin <= highBin; bin++) {
206
+ const mag = frameMags[bin] ?? 0;
207
+ energy += mag * mag;
208
+ }
209
+ bandEnergies[frame] = energy;
210
+ }
211
+
212
+ // Compute variance
213
+ let sum = 0;
214
+ for (let i = 0; i < nFrames; i++) {
215
+ sum += bandEnergies[i] ?? 0;
216
+ }
217
+ const mean = sum / nFrames;
218
+
219
+ let variance = 0;
220
+ for (let i = 0; i < nFrames; i++) {
221
+ const diff = (bandEnergies[i] ?? 0) - mean;
222
+ variance += diff * diff;
223
+ }
224
+ variance /= nFrames;
225
+
226
+ // Normalize by mean squared to get coefficient of variation squared
227
+ return mean > 0 ? variance / (mean * mean) : 0;
228
+ }
229
+
230
+ // ----------------------------
231
+ // Peak Detection and Scoring
232
+ // ----------------------------
233
+
234
+ /**
235
+ * Detect spectral peaks from the average CQT spectrum.
236
+ */
237
+ function detectSpectralPeaks(
238
+ cqt: CqtSpectrogram,
239
+ config: Required<BandProposalConfig>
240
+ ): SpectralPeak[] {
241
+ const avgSpectrum = computeAverageCqtSpectrum(cqt);
242
+
243
+ // Minimum distance in bins based on separation requirement
244
+ const minBinDistance = Math.ceil(config.minSeparationOctaves * cqt.binsPerOctave);
245
+
246
+ const peakIndices = findLocalMaxima(avgSpectrum, Math.max(3, minBinDistance / 2));
247
+
248
+ const peaks: SpectralPeak[] = [];
249
+
250
+ for (const binIndex of peakIndices) {
251
+ const bw = computeBandwidth(avgSpectrum, binIndex, cqt, config.minBandwidthHz);
252
+
253
+ peaks.push({
254
+ binIndex,
255
+ centerHz: cqtBinToHz(binIndex, cqt.config),
256
+ magnitude: avgSpectrum[binIndex] ?? 0,
257
+ bandwidth: bw.bandwidthOctaves,
258
+ lowHz: bw.lowHz,
259
+ highHz: bw.highHz,
260
+ });
261
+ }
262
+
263
+ // Sort by magnitude (descending)
264
+ peaks.sort((a, b) => b.magnitude - a.magnitude);
265
+
266
+ return peaks;
267
+ }
268
+
269
+ /**
270
+ * Score a spectral peak for proposal salience.
271
+ */
272
+ function scorePeak(
273
+ peak: SpectralPeak,
274
+ cqt: CqtSpectrogram,
275
+ avgSpectrum: Float32Array,
276
+ cqtSignals: {
277
+ harmonicEnergy: Float32Array;
278
+ bassPitchMotion: Float32Array;
279
+ tonalStability: Float32Array;
280
+ }
281
+ ): ProposalCandidate {
282
+ // Find bin range for this peak
283
+ const lowBin = Math.max(0, Math.floor(peak.lowHz / (cqt.config.fMin * Math.pow(2, 1 / cqt.binsPerOctave))));
284
+ const highBin = Math.min(
285
+ getNumBins(cqt.config) - 1,
286
+ Math.ceil(peak.highHz / (cqt.config.fMin * Math.pow(2, 1 / cqt.binsPerOctave)))
287
+ );
288
+
289
+ // Compute various scores
290
+ const temporalVariance = computeTemporalVariance(cqt, lowBin, highBin);
291
+
292
+ // Energy concentration: how much energy is in this band vs total
293
+ let bandEnergy = 0;
294
+ let totalEnergy = 0;
295
+ for (let bin = 0; bin < avgSpectrum.length; bin++) {
296
+ const mag = avgSpectrum[bin] ?? 0;
297
+ totalEnergy += mag * mag;
298
+ if (bin >= lowBin && bin <= highBin) {
299
+ bandEnergy += mag * mag;
300
+ }
301
+ }
302
+ const energyConcentration = totalEnergy > 0 ? bandEnergy / totalEnergy : 0;
303
+
304
+ // Get average CQT signal values for this frequency range
305
+ // Map frequency to frame indices (not perfect, but approximate)
306
+ const isBassRange = peak.centerHz < 300;
307
+ const isLowMidRange = peak.centerHz >= 300 && peak.centerHz < 1000;
308
+
309
+ // Compute average signal values
310
+ let avgHarmonicEnergy = 0;
311
+ let avgBassPitchMotion = 0;
312
+ let avgTonalStability = 0;
313
+ const nFrames = cqtSignals.harmonicEnergy.length;
314
+
315
+ for (let i = 0; i < nFrames; i++) {
316
+ avgHarmonicEnergy += cqtSignals.harmonicEnergy[i] ?? 0;
317
+ avgBassPitchMotion += cqtSignals.bassPitchMotion[i] ?? 0;
318
+ avgTonalStability += cqtSignals.tonalStability[i] ?? 0;
319
+ }
320
+ avgHarmonicEnergy /= nFrames;
321
+ avgBassPitchMotion /= nFrames;
322
+ avgTonalStability /= nFrames;
323
+
324
+ // Determine source and compute salience based on characteristics
325
+ let source: BandProposalSource;
326
+ let reason: string;
327
+ let salience: number;
328
+
329
+ if (isBassRange && avgBassPitchMotion > 0.4) {
330
+ source = "cqt_bass_motion";
331
+ reason = "Significant bass pitch motion detected";
332
+ salience = 0.3 + energyConcentration * 0.3 + avgBassPitchMotion * 0.4;
333
+ } else if (avgHarmonicEnergy > 0.5 && avgTonalStability > 0.5) {
334
+ source = "cqt_harmonic";
335
+ reason = "Strong harmonic structure with stable tonality";
336
+ salience = 0.3 + avgHarmonicEnergy * 0.35 + avgTonalStability * 0.35;
337
+ } else if (temporalVariance > 0.5) {
338
+ source = "onset_band";
339
+ reason = "Distinct transient activity pattern";
340
+ salience = 0.3 + energyConcentration * 0.3 + Math.min(1, temporalVariance) * 0.4;
341
+ } else if (energyConcentration > 0.1) {
342
+ source = "energy_cluster";
343
+ reason = "Concentrated spectral energy";
344
+ salience = 0.2 + energyConcentration * 0.5 + (1 - Math.min(1, peak.bandwidth)) * 0.3;
345
+ } else {
346
+ source = "spectral_peak";
347
+ reason = "Persistent spectral peak";
348
+ salience = 0.2 + energyConcentration * 0.4 + (1 - Math.min(1, peak.bandwidth * 2)) * 0.4;
349
+ }
350
+
351
+ // Add frequency-specific context to reason
352
+ if (isBassRange) {
353
+ reason += " (bass region)";
354
+ } else if (isLowMidRange) {
355
+ reason += " (low-mid region)";
356
+ } else if (peak.centerHz > 4000) {
357
+ reason += " (high frequency region)";
358
+ }
359
+
360
+ return {
361
+ peak,
362
+ salience: Math.min(1, Math.max(0, salience)),
363
+ source,
364
+ reason,
365
+ };
366
+ }
367
+
368
+ // ----------------------------
369
+ // Proposal Generation
370
+ // ----------------------------
371
+
372
+ /**
373
+ * Create a FrequencyBand from a proposal candidate.
374
+ */
375
+ function createBandFromCandidate(
376
+ candidate: ProposalCandidate,
377
+ duration: number
378
+ ): FrequencyBand {
379
+ const now = new Date().toISOString();
380
+ const provenance: FrequencyBandProvenance = {
381
+ source: "manual", // Will be treated as imported when promoted
382
+ createdAt: now,
383
+ };
384
+
385
+ return {
386
+ id: generateBandId(),
387
+ label: `Region ${Math.round(candidate.peak.centerHz)} Hz`,
388
+ sourceId: "mixdown", // Proposals default to mixdown; user assigns sourceId on promotion
389
+ enabled: true,
390
+ timeScope: { kind: "global" },
391
+ frequencyShape: [
392
+ {
393
+ startTime: 0,
394
+ endTime: duration,
395
+ lowHzStart: candidate.peak.lowHz,
396
+ highHzStart: candidate.peak.highHz,
397
+ lowHzEnd: candidate.peak.lowHz,
398
+ highHzEnd: candidate.peak.highHz,
399
+ },
400
+ ],
401
+ sortOrder: 0,
402
+ provenance,
403
+ };
404
+ }
405
+
406
+ /**
407
+ * Filter candidates to remove overlapping proposals.
408
+ * Keeps the highest-salience candidate when overlap exceeds threshold.
409
+ */
410
+ function filterOverlappingCandidates(
411
+ candidates: ProposalCandidate[],
412
+ minSeparationOctaves: number
413
+ ): ProposalCandidate[] {
414
+ const filtered: ProposalCandidate[] = [];
415
+
416
+ for (const candidate of candidates) {
417
+ let hasOverlap = false;
418
+
419
+ for (const existing of filtered) {
420
+ const octaveDiff = Math.abs(
421
+ Math.log2(candidate.peak.centerHz / existing.peak.centerHz)
422
+ );
423
+
424
+ if (octaveDiff < minSeparationOctaves) {
425
+ hasOverlap = true;
426
+ break;
427
+ }
428
+ }
429
+
430
+ if (!hasOverlap) {
431
+ filtered.push(candidate);
432
+ }
433
+ }
434
+
435
+ return filtered;
436
+ }
437
+
438
+ // ----------------------------
439
+ // Main Export
440
+ // ----------------------------
441
+
442
+ export type BandProposalOptions = {
443
+ config?: BandProposalConfig;
444
+ isCancelled?: () => boolean;
445
+ };
446
+
447
+ /**
448
+ * Generate band proposals from audio.
449
+ *
450
+ * @param audio - Audio buffer to analyze
451
+ * @param duration - Track duration in seconds
452
+ * @param options - Optional configuration
453
+ * @returns Array of band proposals with salience scores and reasons
454
+ */
455
+ export async function generateBandProposals(
456
+ audio: AudioBufferLike,
457
+ duration: number,
458
+ options: BandProposalOptions = {}
459
+ ): Promise<BandProposalResult> {
460
+ const startTime = performance.now();
461
+
462
+ const config: Required<BandProposalConfig> = {
463
+ ...PROPOSAL_DEFAULTS,
464
+ ...options.config,
465
+ };
466
+
467
+ // Compute CQT
468
+ const cqtConfig = withCqtDefaults({
469
+ binsPerOctave: 24,
470
+ fMin: 32.7,
471
+ fMax: Math.min(8372, audio.sampleRate / 2), // Cap at Nyquist
472
+ });
473
+
474
+ const cqt = await cqtSpectrogram(audio, cqtConfig, {
475
+ isCancelled: options.isCancelled,
476
+ });
477
+
478
+ if (options.isCancelled?.()) {
479
+ throw new Error("@octoseq/mir: cancelled");
480
+ }
481
+
482
+ // Compute CQT-derived signals
483
+ const harmonicResult = harmonicEnergy(cqt);
484
+ const bassMotionResult = bassPitchMotion(cqt);
485
+ const tonalResult = tonalStability(cqt);
486
+
487
+ const cqtSignals = {
488
+ harmonicEnergy: harmonicResult.values,
489
+ bassPitchMotion: bassMotionResult.values,
490
+ tonalStability: tonalResult.values,
491
+ };
492
+
493
+ // Detect spectral peaks
494
+ const peaks = detectSpectralPeaks(cqt, config);
495
+
496
+ // Score each peak
497
+ const avgSpectrum = computeAverageCqtSpectrum(cqt);
498
+ const candidates: ProposalCandidate[] = [];
499
+
500
+ for (const peak of peaks) {
501
+ if (options.isCancelled?.()) {
502
+ throw new Error("@octoseq/mir: cancelled");
503
+ }
504
+
505
+ const candidate = scorePeak(peak, cqt, avgSpectrum, cqtSignals);
506
+
507
+ if (candidate.salience >= config.minSalience) {
508
+ candidates.push(candidate);
509
+ }
510
+ }
511
+
512
+ // Sort by salience (descending)
513
+ candidates.sort((a, b) => b.salience - a.salience);
514
+
515
+ // Filter overlapping candidates
516
+ const filtered = filterOverlappingCandidates(candidates, config.minSeparationOctaves);
517
+
518
+ // Limit to maxProposals
519
+ const finalCandidates = filtered.slice(0, config.maxProposals);
520
+
521
+ // Create proposals
522
+ const proposals: BandProposal[] = finalCandidates.map((candidate, index) => {
523
+ const band = createBandFromCandidate(candidate, duration);
524
+ band.sortOrder = index;
525
+
526
+ return {
527
+ id: generateProposalId(),
528
+ band,
529
+ salience: candidate.salience,
530
+ reason: candidate.reason,
531
+ source: candidate.source,
532
+ generatedAt: new Date().toISOString(),
533
+ };
534
+ });
535
+
536
+ const endTime = performance.now();
537
+
538
+ const meta: MirRunMeta = {
539
+ backend: "cpu",
540
+ usedGpu: false,
541
+ timings: {
542
+ totalMs: endTime - startTime,
543
+ cpuMs: endTime - startTime,
544
+ },
545
+ };
546
+
547
+ return {
548
+ kind: "bandProposals",
549
+ proposals,
550
+ meta,
551
+ };
552
+ }