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