@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,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Band Event Extraction for F3.
|
|
3
|
+
*
|
|
4
|
+
* These functions extract discrete events (onset peaks, beat candidates)
|
|
5
|
+
* from band-scoped 1D signals.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
BandMir1DResult,
|
|
10
|
+
BandEventFunctionId,
|
|
11
|
+
BandEventsResult,
|
|
12
|
+
BandMirEvent,
|
|
13
|
+
BandEventDiagnostics,
|
|
14
|
+
MirRunMeta,
|
|
15
|
+
MirRunTimings,
|
|
16
|
+
} from "../types";
|
|
17
|
+
import { peakPick, type PeakPickOptions } from "./peakPick";
|
|
18
|
+
|
|
19
|
+
// Re-export types for convenience
|
|
20
|
+
export type { BandEventFunctionId, BandEventsResult, BandMirEvent, BandEventDiagnostics };
|
|
21
|
+
|
|
22
|
+
// ----------------------------
|
|
23
|
+
// Options
|
|
24
|
+
// ----------------------------
|
|
25
|
+
|
|
26
|
+
export type BandOnsetPeaksOptions = {
|
|
27
|
+
/** Minimum inter-peak interval in seconds. Default: 0.0625 (~0.125 beats at 120 BPM). */
|
|
28
|
+
minIntervalSec?: number;
|
|
29
|
+
/** Adaptive threshold factor. Default: 0.8 (conservative for bands). */
|
|
30
|
+
adaptiveFactor?: number;
|
|
31
|
+
/** Use strict peak detection (> neighbors). Default: true. */
|
|
32
|
+
strict?: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type BandBeatCandidatesOptions = {
|
|
36
|
+
/** Minimum inter-candidate interval in seconds. Default: 0.1. */
|
|
37
|
+
minIntervalSec?: number;
|
|
38
|
+
/** Threshold factor for adaptive detection. Lower = more candidates. Default: 0.5. */
|
|
39
|
+
thresholdFactor?: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// ----------------------------
|
|
43
|
+
// Internal Helpers
|
|
44
|
+
// ----------------------------
|
|
45
|
+
|
|
46
|
+
function nowMs(): number {
|
|
47
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function createMeta(startMs: number): MirRunMeta {
|
|
51
|
+
const endMs = nowMs();
|
|
52
|
+
const timings: MirRunTimings = {
|
|
53
|
+
totalMs: endMs - startMs,
|
|
54
|
+
cpuMs: endMs - startMs,
|
|
55
|
+
gpuMs: 0,
|
|
56
|
+
};
|
|
57
|
+
return {
|
|
58
|
+
backend: "cpu",
|
|
59
|
+
usedGpu: false,
|
|
60
|
+
timings,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function computeEventDiagnostics(
|
|
65
|
+
events: BandMirEvent[],
|
|
66
|
+
durationSec: number
|
|
67
|
+
): BandEventDiagnostics {
|
|
68
|
+
const eventCount = events.length;
|
|
69
|
+
const eventsPerSecond = durationSec > 0 ? eventCount / durationSec : 0;
|
|
70
|
+
const warnings: string[] = [];
|
|
71
|
+
|
|
72
|
+
// Check for sparse or dense event streams
|
|
73
|
+
if (durationSec > 1 && eventCount === 0) {
|
|
74
|
+
warnings.push("No events detected - signal may be too quiet or noisy");
|
|
75
|
+
} else if (eventsPerSecond > 20) {
|
|
76
|
+
warnings.push("Very high event density (>20/sec) - consider adjusting threshold");
|
|
77
|
+
} else if (durationSec > 10 && eventsPerSecond < 0.1) {
|
|
78
|
+
warnings.push("Very sparse events (<0.1/sec) - signal may not be active");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
eventCount,
|
|
83
|
+
eventsPerSecond,
|
|
84
|
+
warnings,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ----------------------------
|
|
89
|
+
// Band Event Functions
|
|
90
|
+
// ----------------------------
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract onset peaks from a band MIR 1D signal.
|
|
94
|
+
*
|
|
95
|
+
* Uses peak picking with conservative defaults optimized for band-scoped
|
|
96
|
+
* extraction. Typically applied to bandOnsetStrength or bandAmplitudeEnvelope.
|
|
97
|
+
*
|
|
98
|
+
* @param signal - The band MIR 1D result to extract peaks from
|
|
99
|
+
* @param options - Peak picking options
|
|
100
|
+
* @returns Band events result with onset peaks
|
|
101
|
+
*/
|
|
102
|
+
export function bandOnsetPeaks(
|
|
103
|
+
signal: BandMir1DResult,
|
|
104
|
+
options?: BandOnsetPeaksOptions
|
|
105
|
+
): BandEventsResult {
|
|
106
|
+
const startMs = nowMs();
|
|
107
|
+
|
|
108
|
+
const minIntervalSec = options?.minIntervalSec ?? 0.0625;
|
|
109
|
+
const adaptiveFactor = options?.adaptiveFactor ?? 0.8;
|
|
110
|
+
const strict = options?.strict ?? true;
|
|
111
|
+
|
|
112
|
+
const { times, values } = signal;
|
|
113
|
+
|
|
114
|
+
// Peak pick the signal
|
|
115
|
+
const pickOptions: PeakPickOptions = {
|
|
116
|
+
minIntervalSec,
|
|
117
|
+
adaptive: {
|
|
118
|
+
method: "meanStd",
|
|
119
|
+
factor: adaptiveFactor,
|
|
120
|
+
},
|
|
121
|
+
strict,
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const peaks = peakPick(times, values, pickOptions);
|
|
125
|
+
|
|
126
|
+
// Normalize weights to 0-1 range
|
|
127
|
+
const maxStrength = peaks.reduce((max, p) => Math.max(max, p.strength), 0);
|
|
128
|
+
|
|
129
|
+
const events: BandMirEvent[] = peaks.map((p) => ({
|
|
130
|
+
time: p.time,
|
|
131
|
+
weight: maxStrength > 0 ? p.strength / maxStrength : 1,
|
|
132
|
+
}));
|
|
133
|
+
|
|
134
|
+
// Compute duration for diagnostics
|
|
135
|
+
const duration = times.length >= 2
|
|
136
|
+
? (times[times.length - 1] ?? 0) - (times[0] ?? 0)
|
|
137
|
+
: 0;
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
kind: "bandEvents",
|
|
141
|
+
bandId: signal.bandId,
|
|
142
|
+
bandLabel: signal.bandLabel,
|
|
143
|
+
fn: "bandOnsetPeaks",
|
|
144
|
+
events,
|
|
145
|
+
sourceSignal: {
|
|
146
|
+
fn: signal.fn,
|
|
147
|
+
times: signal.times,
|
|
148
|
+
values: signal.values,
|
|
149
|
+
},
|
|
150
|
+
meta: createMeta(startMs),
|
|
151
|
+
diagnostics: computeEventDiagnostics(events, duration),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Extract beat candidates from a band's onset peaks.
|
|
157
|
+
*
|
|
158
|
+
* Similar to full-track beat candidate detection but simplified for
|
|
159
|
+
* single-band input. Uses the onset peaks to identify beat-like events.
|
|
160
|
+
*
|
|
161
|
+
* @param onsetPeaks - Band onset peaks result
|
|
162
|
+
* @param options - Beat candidate options
|
|
163
|
+
* @returns Band events result with beat candidates
|
|
164
|
+
*/
|
|
165
|
+
export function bandBeatCandidates(
|
|
166
|
+
onsetPeaks: BandEventsResult,
|
|
167
|
+
options?: BandBeatCandidatesOptions
|
|
168
|
+
): BandEventsResult {
|
|
169
|
+
const startMs = nowMs();
|
|
170
|
+
|
|
171
|
+
const minIntervalSec = options?.minIntervalSec ?? 0.1;
|
|
172
|
+
const thresholdFactor = options?.thresholdFactor ?? 0.5;
|
|
173
|
+
|
|
174
|
+
// Filter onset peaks by strength threshold
|
|
175
|
+
// Use adaptive threshold similar to full-track beat candidates
|
|
176
|
+
const weights = onsetPeaks.events.map((e) => e.weight);
|
|
177
|
+
const meanWeight = weights.length > 0
|
|
178
|
+
? weights.reduce((sum, w) => sum + w, 0) / weights.length
|
|
179
|
+
: 0;
|
|
180
|
+
|
|
181
|
+
// Simple std calculation
|
|
182
|
+
const variance = weights.length > 0
|
|
183
|
+
? weights.reduce((sum, w) => sum + (w - meanWeight) ** 2, 0) / weights.length
|
|
184
|
+
: 0;
|
|
185
|
+
const stdWeight = Math.sqrt(variance);
|
|
186
|
+
|
|
187
|
+
const threshold = meanWeight + thresholdFactor * stdWeight;
|
|
188
|
+
|
|
189
|
+
// Filter by threshold and minimum interval
|
|
190
|
+
const candidates: BandMirEvent[] = [];
|
|
191
|
+
let lastTime = -Infinity;
|
|
192
|
+
|
|
193
|
+
for (const event of onsetPeaks.events) {
|
|
194
|
+
if (event.weight < threshold) continue;
|
|
195
|
+
if (event.time - lastTime < minIntervalSec) {
|
|
196
|
+
// Keep stronger event within interval
|
|
197
|
+
const last = candidates[candidates.length - 1];
|
|
198
|
+
if (last && event.weight > last.weight) {
|
|
199
|
+
last.time = event.time;
|
|
200
|
+
last.weight = event.weight;
|
|
201
|
+
lastTime = event.time;
|
|
202
|
+
}
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
candidates.push({
|
|
207
|
+
time: event.time,
|
|
208
|
+
weight: event.weight,
|
|
209
|
+
beatPosition: event.beatPosition,
|
|
210
|
+
beatPhase: event.beatPhase,
|
|
211
|
+
});
|
|
212
|
+
lastTime = event.time;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Compute duration from source signal if available
|
|
216
|
+
const duration = onsetPeaks.sourceSignal?.times
|
|
217
|
+
? (onsetPeaks.sourceSignal.times.length >= 2
|
|
218
|
+
? (onsetPeaks.sourceSignal.times[onsetPeaks.sourceSignal.times.length - 1] ?? 0)
|
|
219
|
+
- (onsetPeaks.sourceSignal.times[0] ?? 0)
|
|
220
|
+
: 0)
|
|
221
|
+
: 0;
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
kind: "bandEvents",
|
|
225
|
+
bandId: onsetPeaks.bandId,
|
|
226
|
+
bandLabel: onsetPeaks.bandLabel,
|
|
227
|
+
fn: "bandBeatCandidates",
|
|
228
|
+
events: candidates,
|
|
229
|
+
// Don't include source signal to save memory
|
|
230
|
+
meta: createMeta(startMs),
|
|
231
|
+
diagnostics: computeEventDiagnostics(candidates, duration),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ----------------------------
|
|
236
|
+
// Batch Runner
|
|
237
|
+
// ----------------------------
|
|
238
|
+
|
|
239
|
+
export type BandEventsBatchRequest = {
|
|
240
|
+
/** Band MIR results to extract events from (keyed by bandId) */
|
|
241
|
+
bandMirResults: Map<string, BandMir1DResult[]>;
|
|
242
|
+
/** Event functions to run */
|
|
243
|
+
functions: BandEventFunctionId[];
|
|
244
|
+
/** Source signal function to use for onset peaks. Default: bandOnsetStrength */
|
|
245
|
+
sourceFunction?: "bandOnsetStrength" | "bandAmplitudeEnvelope";
|
|
246
|
+
/** Options for onset peaks extraction */
|
|
247
|
+
onsetPeaksOptions?: BandOnsetPeaksOptions;
|
|
248
|
+
/** Options for beat candidates extraction */
|
|
249
|
+
beatCandidatesOptions?: BandBeatCandidatesOptions;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
export type BandEventsBatchResult = {
|
|
253
|
+
/** Results keyed by bandId, each containing results for requested functions */
|
|
254
|
+
results: Map<string, BandEventsResult[]>;
|
|
255
|
+
/** Total computation time in ms */
|
|
256
|
+
totalTimingMs: number;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Run band event extraction for multiple bands.
|
|
261
|
+
*
|
|
262
|
+
* @param request - Batch request specifying bands, functions, and options
|
|
263
|
+
* @returns Map of results by band ID
|
|
264
|
+
*/
|
|
265
|
+
export async function runBandEventsBatch(
|
|
266
|
+
request: BandEventsBatchRequest
|
|
267
|
+
): Promise<BandEventsBatchResult> {
|
|
268
|
+
const startMs = nowMs();
|
|
269
|
+
|
|
270
|
+
const results = new Map<string, BandEventsResult[]>();
|
|
271
|
+
const sourceFunction = request.sourceFunction ?? "bandOnsetStrength";
|
|
272
|
+
|
|
273
|
+
for (const [bandId, mirResults] of request.bandMirResults.entries()) {
|
|
274
|
+
const bandEventResults: BandEventsResult[] = [];
|
|
275
|
+
|
|
276
|
+
// Find the source signal for onset peaks
|
|
277
|
+
const sourceSignal = mirResults.find((r) => r.fn === sourceFunction);
|
|
278
|
+
if (!sourceSignal) {
|
|
279
|
+
// Skip if source signal not available
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
for (const fn of request.functions) {
|
|
284
|
+
switch (fn) {
|
|
285
|
+
case "bandOnsetPeaks": {
|
|
286
|
+
const result = bandOnsetPeaks(sourceSignal, request.onsetPeaksOptions);
|
|
287
|
+
bandEventResults.push(result);
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
case "bandBeatCandidates": {
|
|
291
|
+
// Beat candidates require onset peaks first
|
|
292
|
+
let onsetPeaksResult = bandEventResults.find(
|
|
293
|
+
(r) => r.fn === "bandOnsetPeaks"
|
|
294
|
+
);
|
|
295
|
+
if (!onsetPeaksResult) {
|
|
296
|
+
// Compute onset peaks if not already done
|
|
297
|
+
onsetPeaksResult = bandOnsetPeaks(
|
|
298
|
+
sourceSignal,
|
|
299
|
+
request.onsetPeaksOptions
|
|
300
|
+
);
|
|
301
|
+
// Only add to results if explicitly requested
|
|
302
|
+
if (!request.functions.includes("bandOnsetPeaks")) {
|
|
303
|
+
// Don't add to results, just use for beat candidates
|
|
304
|
+
} else {
|
|
305
|
+
bandEventResults.push(onsetPeaksResult);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
const result = bandBeatCandidates(
|
|
309
|
+
onsetPeaksResult,
|
|
310
|
+
request.beatCandidatesOptions
|
|
311
|
+
);
|
|
312
|
+
bandEventResults.push(result);
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
default: {
|
|
316
|
+
// Exhaustive check
|
|
317
|
+
const _exhaustive: never = fn;
|
|
318
|
+
throw new Error(`Unknown band event function: ${_exhaustive}`);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (bandEventResults.length > 0) {
|
|
324
|
+
results.set(bandId, bandEventResults);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const endMs = nowMs();
|
|
329
|
+
|
|
330
|
+
return {
|
|
331
|
+
results,
|
|
332
|
+
totalTimingMs: endMs - startMs,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Get a human-readable label for a band event function.
|
|
338
|
+
*
|
|
339
|
+
* @param fn - Band event function ID
|
|
340
|
+
* @returns Human-readable label
|
|
341
|
+
*/
|
|
342
|
+
export function getBandEventFunctionLabel(fn: BandEventFunctionId): string {
|
|
343
|
+
switch (fn) {
|
|
344
|
+
case "bandOnsetPeaks":
|
|
345
|
+
return "Onset Peaks";
|
|
346
|
+
case "bandBeatCandidates":
|
|
347
|
+
return "Beat Candidates";
|
|
348
|
+
default:
|
|
349
|
+
return fn;
|
|
350
|
+
}
|
|
351
|
+
}
|
package/src/dsp/bandMir.ts
CHANGED
|
@@ -350,6 +350,70 @@ export function bandSpectralFlux(
|
|
|
350
350
|
};
|
|
351
351
|
}
|
|
352
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
|
+
|
|
353
417
|
// ----------------------------
|
|
354
418
|
// Batch Runner
|
|
355
419
|
// ----------------------------
|
|
@@ -415,6 +479,9 @@ export async function runBandMirBatch(
|
|
|
415
479
|
case "bandSpectralFlux":
|
|
416
480
|
result = bandSpectralFlux(spec, band, options);
|
|
417
481
|
break;
|
|
482
|
+
case "bandSpectralCentroid":
|
|
483
|
+
result = bandSpectralCentroid(spec, band, options);
|
|
484
|
+
break;
|
|
418
485
|
default:
|
|
419
486
|
// Exhaustive check
|
|
420
487
|
const _exhaustive: never = fn;
|
|
@@ -449,6 +516,8 @@ export function getBandMirFunctionLabel(fn: BandMirFunctionId): string {
|
|
|
449
516
|
return "Onset Strength";
|
|
450
517
|
case "bandSpectralFlux":
|
|
451
518
|
return "Spectral Flux";
|
|
519
|
+
case "bandSpectralCentroid":
|
|
520
|
+
return "Spectral Centroid";
|
|
452
521
|
default:
|
|
453
522
|
return fn;
|
|
454
523
|
}
|
package/src/dsp/bandProposal.ts
CHANGED
|
@@ -33,6 +33,7 @@ const PROPOSAL_DEFAULTS: Required<BandProposalConfig> = {
|
|
|
33
33
|
maxProposals: 8,
|
|
34
34
|
minSalience: 0.3,
|
|
35
35
|
minSeparationOctaves: 0.5,
|
|
36
|
+
minBandwidthHz: 20,
|
|
36
37
|
analysisWindow: 0, // 0 = full track
|
|
37
38
|
};
|
|
38
39
|
|
|
@@ -133,7 +134,8 @@ function findLocalMaxima(
|
|
|
133
134
|
function computeBandwidth(
|
|
134
135
|
values: Float32Array,
|
|
135
136
|
peakBin: number,
|
|
136
|
-
cqt: CqtSpectrogram
|
|
137
|
+
cqt: CqtSpectrogram,
|
|
138
|
+
minBandwidthHz: number
|
|
137
139
|
): { lowBin: number; highBin: number; lowHz: number; highHz: number; bandwidthOctaves: number } {
|
|
138
140
|
const peakMag = values[peakBin] ?? 0;
|
|
139
141
|
const threshold = peakMag * 0.707; // -3dB
|
|
@@ -150,6 +152,31 @@ function computeBandwidth(
|
|
|
150
152
|
highBin++;
|
|
151
153
|
}
|
|
152
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
|
+
|
|
153
180
|
const lowHz = cqtBinToHz(lowBin, cqt.config);
|
|
154
181
|
const highHz = cqtBinToHz(highBin, cqt.config);
|
|
155
182
|
const bandwidthOctaves = Math.log2(highHz / lowHz);
|
|
@@ -221,7 +248,7 @@ function detectSpectralPeaks(
|
|
|
221
248
|
const peaks: SpectralPeak[] = [];
|
|
222
249
|
|
|
223
250
|
for (const binIndex of peakIndices) {
|
|
224
|
-
const bw = computeBandwidth(avgSpectrum, binIndex, cqt);
|
|
251
|
+
const bw = computeBandwidth(avgSpectrum, binIndex, cqt, config.minBandwidthHz);
|
|
225
252
|
|
|
226
253
|
peaks.push({
|
|
227
254
|
binIndex,
|
|
@@ -358,6 +385,7 @@ function createBandFromCandidate(
|
|
|
358
385
|
return {
|
|
359
386
|
id: generateBandId(),
|
|
360
387
|
label: `Region ${Math.round(candidate.peak.centerHz)} Hz`,
|
|
388
|
+
sourceId: "mixdown", // Proposals default to mixdown; user assigns sourceId on promotion
|
|
361
389
|
enabled: true,
|
|
362
390
|
timeScope: { kind: "global" },
|
|
363
391
|
frequencyShape: [
|