@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.
@@ -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
+ }
@@ -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
  }
@@ -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: [