@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,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Silence-aware gating for onset detection.
|
|
3
|
+
*
|
|
4
|
+
* This module implements a production-grade silence detection and activity masking
|
|
5
|
+
* pipeline. Silence is treated as a first-class state, not "low energy sound".
|
|
6
|
+
*
|
|
7
|
+
* Pipeline stages:
|
|
8
|
+
* 1. Frame-level energy computation
|
|
9
|
+
* 2. Adaptive noise floor estimation (percentile-based)
|
|
10
|
+
* 3. Activity mask with hysteresis and hangover
|
|
11
|
+
* 4. Post-silence suppression window
|
|
12
|
+
*
|
|
13
|
+
* Design principles:
|
|
14
|
+
* - No fixed absolute thresholds - all thresholds are relative to estimated noise floor
|
|
15
|
+
* - Hysteresis prevents oscillation at threshold boundaries
|
|
16
|
+
* - Hangover prevents premature exit from active state
|
|
17
|
+
* - Post-silence suppression prevents the first audible frame from being an onset
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Configuration for silence-aware gating.
|
|
22
|
+
*/
|
|
23
|
+
export type SilenceGateConfig = {
|
|
24
|
+
/**
|
|
25
|
+
* Whether gating is enabled. If false, all frames are considered active.
|
|
26
|
+
* @default true
|
|
27
|
+
*/
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Percentile (0-100) used to estimate the noise floor from frame energies.
|
|
32
|
+
* Lower values are more conservative (estimate floor from quieter frames).
|
|
33
|
+
* @default 10
|
|
34
|
+
*/
|
|
35
|
+
energyPercentile?: number;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Margin above noise floor (in the same units as frame energy) to enter active state.
|
|
39
|
+
* For log-scale energies (mel), this is effectively dB.
|
|
40
|
+
* @default 6 (approximately 6 dB above floor for mel-based energy)
|
|
41
|
+
*/
|
|
42
|
+
enterMargin?: number;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Margin above noise floor to remain in active state.
|
|
46
|
+
* Must be less than enterMargin for proper hysteresis.
|
|
47
|
+
* @default 3 (approximately 3 dB above floor)
|
|
48
|
+
*/
|
|
49
|
+
exitMargin?: number;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Duration in milliseconds to remain active after energy drops below exit threshold.
|
|
53
|
+
* Prevents premature exit during brief dips.
|
|
54
|
+
* @default 50
|
|
55
|
+
*/
|
|
56
|
+
hangoverMs?: number;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Minimum duration in milliseconds a region must be active before allowing onsets.
|
|
60
|
+
* @default 0
|
|
61
|
+
*/
|
|
62
|
+
minActiveMs?: number;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Duration in milliseconds after transitioning from inactive to active
|
|
66
|
+
* during which onsets are suppressed.
|
|
67
|
+
* Prevents the first audible frame from being detected as an onset.
|
|
68
|
+
* @default 50
|
|
69
|
+
*/
|
|
70
|
+
postSilenceSuppressMs?: number;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Configuration for bin-level gating (CPU paths only).
|
|
75
|
+
* Ignores bins whose energy is below a relative floor.
|
|
76
|
+
*/
|
|
77
|
+
export type BinGateConfig = {
|
|
78
|
+
/**
|
|
79
|
+
* Whether bin-level gating is enabled.
|
|
80
|
+
* @default true
|
|
81
|
+
*/
|
|
82
|
+
enabled?: boolean;
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Relative floor as a fraction of frame energy.
|
|
86
|
+
* Bins with value < frameEnergy * binFloorRel are ignored.
|
|
87
|
+
* @default 0.05
|
|
88
|
+
*/
|
|
89
|
+
binFloorRel?: number;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Result of silence gating analysis.
|
|
94
|
+
* Useful for debugging and visualization.
|
|
95
|
+
*/
|
|
96
|
+
export type SilenceGateResult = {
|
|
97
|
+
/** Per-frame energy values used for gating. */
|
|
98
|
+
frameEnergy: Float32Array;
|
|
99
|
+
|
|
100
|
+
/** Estimated noise floor value. */
|
|
101
|
+
noiseFloor: number;
|
|
102
|
+
|
|
103
|
+
/** Threshold for entering active state. */
|
|
104
|
+
enterThreshold: number;
|
|
105
|
+
|
|
106
|
+
/** Threshold for exiting active state. */
|
|
107
|
+
exitThreshold: number;
|
|
108
|
+
|
|
109
|
+
/** Per-frame activity mask (true = active). */
|
|
110
|
+
activityMask: Uint8Array;
|
|
111
|
+
|
|
112
|
+
/** Per-frame onset suppression mask (true = suppress onset). */
|
|
113
|
+
suppressionMask: Uint8Array;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// Default configuration values
|
|
117
|
+
const DEFAULT_SILENCE_GATE_CONFIG: Required<SilenceGateConfig> = {
|
|
118
|
+
enabled: false, // Disabled by default to avoid breaking existing behavior
|
|
119
|
+
energyPercentile: 10,
|
|
120
|
+
enterMargin: 6,
|
|
121
|
+
exitMargin: 3,
|
|
122
|
+
hangoverMs: 50,
|
|
123
|
+
minActiveMs: 0,
|
|
124
|
+
postSilenceSuppressMs: 50,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const DEFAULT_BIN_GATE_CONFIG: Required<BinGateConfig> = {
|
|
128
|
+
enabled: true,
|
|
129
|
+
binFloorRel: 0.05,
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Merge user config with defaults.
|
|
134
|
+
*/
|
|
135
|
+
export function withSilenceGateDefaults(config?: SilenceGateConfig): Required<SilenceGateConfig> {
|
|
136
|
+
return {
|
|
137
|
+
enabled: config?.enabled ?? DEFAULT_SILENCE_GATE_CONFIG.enabled,
|
|
138
|
+
energyPercentile: config?.energyPercentile ?? DEFAULT_SILENCE_GATE_CONFIG.energyPercentile,
|
|
139
|
+
enterMargin: config?.enterMargin ?? DEFAULT_SILENCE_GATE_CONFIG.enterMargin,
|
|
140
|
+
exitMargin: config?.exitMargin ?? DEFAULT_SILENCE_GATE_CONFIG.exitMargin,
|
|
141
|
+
hangoverMs: config?.hangoverMs ?? DEFAULT_SILENCE_GATE_CONFIG.hangoverMs,
|
|
142
|
+
minActiveMs: config?.minActiveMs ?? DEFAULT_SILENCE_GATE_CONFIG.minActiveMs,
|
|
143
|
+
postSilenceSuppressMs: config?.postSilenceSuppressMs ?? DEFAULT_SILENCE_GATE_CONFIG.postSilenceSuppressMs,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Merge user config with defaults for bin gating.
|
|
149
|
+
*/
|
|
150
|
+
export function withBinGateDefaults(config?: BinGateConfig): Required<BinGateConfig> {
|
|
151
|
+
return {
|
|
152
|
+
enabled: config?.enabled ?? DEFAULT_BIN_GATE_CONFIG.enabled,
|
|
153
|
+
binFloorRel: config?.binFloorRel ?? DEFAULT_BIN_GATE_CONFIG.binFloorRel,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Compute per-frame energy from mel spectrogram.
|
|
159
|
+
*
|
|
160
|
+
* Since mel bands are already log10(eps + energy), we compute the mean
|
|
161
|
+
* of the log-energy values. This gives us a perceptually-relevant
|
|
162
|
+
* frame energy proxy in log space.
|
|
163
|
+
*
|
|
164
|
+
* @param melBands - Array of frames, each containing mel band values (log10 scale)
|
|
165
|
+
* @returns Per-frame energy values (log scale)
|
|
166
|
+
*/
|
|
167
|
+
export function computeFrameEnergyFromMel(melBands: Float32Array[]): Float32Array {
|
|
168
|
+
const nFrames = melBands.length;
|
|
169
|
+
const energy = new Float32Array(nFrames);
|
|
170
|
+
|
|
171
|
+
for (let t = 0; t < nFrames; t++) {
|
|
172
|
+
const bands = melBands[t];
|
|
173
|
+
if (!bands || bands.length === 0) {
|
|
174
|
+
energy[t] = -100; // Very low energy for empty frames
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Mean of log energies (equivalent to log of geometric mean of linear energies)
|
|
179
|
+
let sum = 0;
|
|
180
|
+
for (let m = 0; m < bands.length; m++) {
|
|
181
|
+
sum += bands[m] ?? -100;
|
|
182
|
+
}
|
|
183
|
+
energy[t] = sum / bands.length;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return energy;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Compute per-frame energy from linear magnitude spectrogram.
|
|
191
|
+
*
|
|
192
|
+
* Uses mean magnitude (or power) across bins as the energy proxy.
|
|
193
|
+
*
|
|
194
|
+
* @param magnitudes - Array of frames, each containing magnitude values (linear scale)
|
|
195
|
+
* @param usePower - If true, use sum of squared magnitudes (power). Default: false (RMS-like).
|
|
196
|
+
* @returns Per-frame energy values (linear scale)
|
|
197
|
+
*/
|
|
198
|
+
export function computeFrameEnergyFromSpectrogram(
|
|
199
|
+
magnitudes: Float32Array[],
|
|
200
|
+
usePower = false
|
|
201
|
+
): Float32Array {
|
|
202
|
+
const nFrames = magnitudes.length;
|
|
203
|
+
const energy = new Float32Array(nFrames);
|
|
204
|
+
|
|
205
|
+
for (let t = 0; t < nFrames; t++) {
|
|
206
|
+
const mags = magnitudes[t];
|
|
207
|
+
if (!mags || mags.length === 0) {
|
|
208
|
+
energy[t] = 0;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
let sum = 0;
|
|
213
|
+
for (let k = 0; k < mags.length; k++) {
|
|
214
|
+
const m = mags[k] ?? 0;
|
|
215
|
+
sum += usePower ? m * m : m;
|
|
216
|
+
}
|
|
217
|
+
energy[t] = sum / mags.length;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return energy;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Compute percentile of values.
|
|
225
|
+
*
|
|
226
|
+
* @param values - Input array
|
|
227
|
+
* @param percentile - Percentile to compute (0-100)
|
|
228
|
+
* @returns The percentile value
|
|
229
|
+
*/
|
|
230
|
+
export function computePercentile(values: Float32Array, percentile: number): number {
|
|
231
|
+
if (values.length === 0) return 0;
|
|
232
|
+
|
|
233
|
+
// Create a sorted copy
|
|
234
|
+
const sorted = Float32Array.from(values).sort((a, b) => a - b);
|
|
235
|
+
|
|
236
|
+
// Clamp percentile
|
|
237
|
+
const p = Math.max(0, Math.min(100, percentile));
|
|
238
|
+
const idx = (p / 100) * (sorted.length - 1);
|
|
239
|
+
const lower = Math.floor(idx);
|
|
240
|
+
const upper = Math.ceil(idx);
|
|
241
|
+
|
|
242
|
+
if (lower === upper) {
|
|
243
|
+
return sorted[lower] ?? 0;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Linear interpolation
|
|
247
|
+
const frac = idx - lower;
|
|
248
|
+
const lowVal = sorted[lower] ?? 0;
|
|
249
|
+
const highVal = sorted[upper] ?? 0;
|
|
250
|
+
return lowVal + frac * (highVal - lowVal);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Estimate adaptive noise floor from frame energies.
|
|
255
|
+
*
|
|
256
|
+
* Uses a robust percentile-based estimation rather than absolute thresholds.
|
|
257
|
+
*
|
|
258
|
+
* @param frameEnergy - Per-frame energy values
|
|
259
|
+
* @param percentile - Percentile to use for floor estimation (0-100)
|
|
260
|
+
* @returns Estimated noise floor
|
|
261
|
+
*/
|
|
262
|
+
export function estimateNoiseFloor(frameEnergy: Float32Array, percentile: number): number {
|
|
263
|
+
return computePercentile(frameEnergy, percentile);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Build activity mask with hysteresis and hangover.
|
|
268
|
+
*
|
|
269
|
+
* State machine:
|
|
270
|
+
* - INACTIVE: energy < enterThreshold -> stay inactive
|
|
271
|
+
* - INACTIVE: energy >= enterThreshold -> enter ACTIVE
|
|
272
|
+
* - ACTIVE: energy >= exitThreshold -> stay active, reset hangover counter
|
|
273
|
+
* - ACTIVE: energy < exitThreshold -> decrement hangover counter
|
|
274
|
+
* - ACTIVE: hangover counter exhausted -> enter INACTIVE
|
|
275
|
+
*
|
|
276
|
+
* @param frameEnergy - Per-frame energy values
|
|
277
|
+
* @param enterThreshold - Threshold to enter active state
|
|
278
|
+
* @param exitThreshold - Threshold to remain in active state
|
|
279
|
+
* @param hangoverFrames - Number of frames to remain active after dropping below exit threshold
|
|
280
|
+
* @returns Activity mask (1 = active, 0 = inactive)
|
|
281
|
+
*/
|
|
282
|
+
export function buildActivityMask(
|
|
283
|
+
frameEnergy: Float32Array,
|
|
284
|
+
enterThreshold: number,
|
|
285
|
+
exitThreshold: number,
|
|
286
|
+
hangoverFrames: number
|
|
287
|
+
): Uint8Array {
|
|
288
|
+
const nFrames = frameEnergy.length;
|
|
289
|
+
const mask = new Uint8Array(nFrames);
|
|
290
|
+
|
|
291
|
+
let isActive = false;
|
|
292
|
+
let hangoverRemaining = 0;
|
|
293
|
+
|
|
294
|
+
for (let t = 0; t < nFrames; t++) {
|
|
295
|
+
const e = frameEnergy[t] ?? 0;
|
|
296
|
+
|
|
297
|
+
if (!isActive) {
|
|
298
|
+
// Inactive state: check if we should enter active
|
|
299
|
+
if (e >= enterThreshold) {
|
|
300
|
+
isActive = true;
|
|
301
|
+
hangoverRemaining = hangoverFrames;
|
|
302
|
+
mask[t] = 1;
|
|
303
|
+
} else {
|
|
304
|
+
mask[t] = 0;
|
|
305
|
+
}
|
|
306
|
+
} else {
|
|
307
|
+
// Active state: check if we should remain active
|
|
308
|
+
if (e >= exitThreshold) {
|
|
309
|
+
// Energy is above exit threshold, reset hangover
|
|
310
|
+
hangoverRemaining = hangoverFrames;
|
|
311
|
+
mask[t] = 1;
|
|
312
|
+
} else if (hangoverRemaining > 0) {
|
|
313
|
+
// Energy dropped but hangover not exhausted
|
|
314
|
+
hangoverRemaining--;
|
|
315
|
+
mask[t] = 1;
|
|
316
|
+
} else {
|
|
317
|
+
// Hangover exhausted, enter inactive
|
|
318
|
+
isActive = false;
|
|
319
|
+
mask[t] = 0;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
return mask;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Build suppression mask for post-silence onset suppression.
|
|
329
|
+
*
|
|
330
|
+
* When transitioning from inactive to active, suppress onsets for a window
|
|
331
|
+
* to prevent the first audible frame from being detected as an onset.
|
|
332
|
+
*
|
|
333
|
+
* @param activityMask - Activity mask from buildActivityMask
|
|
334
|
+
* @param suppressFrames - Number of frames to suppress after entering active state
|
|
335
|
+
* @returns Suppression mask (1 = suppress onset, 0 = allow onset)
|
|
336
|
+
*/
|
|
337
|
+
export function buildSuppressionMask(
|
|
338
|
+
activityMask: Uint8Array,
|
|
339
|
+
suppressFrames: number
|
|
340
|
+
): Uint8Array {
|
|
341
|
+
const nFrames = activityMask.length;
|
|
342
|
+
const suppress = new Uint8Array(nFrames);
|
|
343
|
+
|
|
344
|
+
let suppressRemaining = 0;
|
|
345
|
+
let wasActive = false;
|
|
346
|
+
|
|
347
|
+
for (let t = 0; t < nFrames; t++) {
|
|
348
|
+
const isActive = (activityMask[t] ?? 0) === 1;
|
|
349
|
+
|
|
350
|
+
if (isActive && !wasActive) {
|
|
351
|
+
// Just transitioned from inactive to active
|
|
352
|
+
suppressRemaining = suppressFrames;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (isActive && suppressRemaining > 0) {
|
|
356
|
+
suppress[t] = 1;
|
|
357
|
+
suppressRemaining--;
|
|
358
|
+
} else {
|
|
359
|
+
suppress[t] = 0;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
wasActive = isActive;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return suppress;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Apply minimum active duration requirement.
|
|
370
|
+
*
|
|
371
|
+
* Regions that are active for less than minActiveFrames are set to inactive.
|
|
372
|
+
*
|
|
373
|
+
* @param activityMask - Activity mask to modify (in place)
|
|
374
|
+
* @param minActiveFrames - Minimum number of consecutive active frames required
|
|
375
|
+
*/
|
|
376
|
+
export function applyMinActiveDuration(
|
|
377
|
+
activityMask: Uint8Array,
|
|
378
|
+
minActiveFrames: number
|
|
379
|
+
): void {
|
|
380
|
+
if (minActiveFrames <= 1) return;
|
|
381
|
+
|
|
382
|
+
const nFrames = activityMask.length;
|
|
383
|
+
|
|
384
|
+
// Find all active regions and their lengths
|
|
385
|
+
type Region = { start: number; end: number };
|
|
386
|
+
const regions: Region[] = [];
|
|
387
|
+
let regionStart = -1;
|
|
388
|
+
|
|
389
|
+
for (let t = 0; t <= nFrames; t++) {
|
|
390
|
+
const isActive = t < nFrames && (activityMask[t] ?? 0) === 1;
|
|
391
|
+
|
|
392
|
+
if (isActive && regionStart < 0) {
|
|
393
|
+
regionStart = t;
|
|
394
|
+
} else if (!isActive && regionStart >= 0) {
|
|
395
|
+
regions.push({ start: regionStart, end: t });
|
|
396
|
+
regionStart = -1;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Suppress regions that are too short
|
|
401
|
+
for (const region of regions) {
|
|
402
|
+
const length = region.end - region.start;
|
|
403
|
+
if (length < minActiveFrames) {
|
|
404
|
+
for (let t = region.start; t < region.end; t++) {
|
|
405
|
+
activityMask[t] = 0;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Full silence gating analysis.
|
|
413
|
+
*
|
|
414
|
+
* Computes frame energy, noise floor, thresholds, activity mask, and suppression mask.
|
|
415
|
+
*
|
|
416
|
+
* @param frameEnergy - Per-frame energy values
|
|
417
|
+
* @param frameDurationSec - Duration of one frame in seconds
|
|
418
|
+
* @param config - Silence gate configuration
|
|
419
|
+
* @returns Complete gating result including all intermediate values
|
|
420
|
+
*/
|
|
421
|
+
export function computeSilenceGating(
|
|
422
|
+
frameEnergy: Float32Array,
|
|
423
|
+
frameDurationSec: number,
|
|
424
|
+
config?: SilenceGateConfig
|
|
425
|
+
): SilenceGateResult {
|
|
426
|
+
const cfg = withSilenceGateDefaults(config);
|
|
427
|
+
|
|
428
|
+
const nFrames = frameEnergy.length;
|
|
429
|
+
|
|
430
|
+
// If gating is disabled, return all-active mask
|
|
431
|
+
if (!cfg.enabled) {
|
|
432
|
+
return {
|
|
433
|
+
frameEnergy,
|
|
434
|
+
noiseFloor: -Infinity,
|
|
435
|
+
enterThreshold: -Infinity,
|
|
436
|
+
exitThreshold: -Infinity,
|
|
437
|
+
activityMask: new Uint8Array(nFrames).fill(1),
|
|
438
|
+
suppressionMask: new Uint8Array(nFrames), // All zeros = no suppression
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Step 1: Estimate noise floor
|
|
443
|
+
const noiseFloor = estimateNoiseFloor(frameEnergy, cfg.energyPercentile);
|
|
444
|
+
|
|
445
|
+
// Step 2: Compute thresholds
|
|
446
|
+
const enterThreshold = noiseFloor + cfg.enterMargin;
|
|
447
|
+
const exitThreshold = noiseFloor + cfg.exitMargin;
|
|
448
|
+
|
|
449
|
+
// Step 3: Convert time parameters to frames
|
|
450
|
+
const hangoverFrames = Math.max(0, Math.round(cfg.hangoverMs / 1000 / frameDurationSec));
|
|
451
|
+
const suppressFrames = Math.max(0, Math.round(cfg.postSilenceSuppressMs / 1000 / frameDurationSec));
|
|
452
|
+
const minActiveFrames = Math.max(0, Math.round(cfg.minActiveMs / 1000 / frameDurationSec));
|
|
453
|
+
|
|
454
|
+
// Step 4: Build activity mask with hysteresis
|
|
455
|
+
const activityMask = buildActivityMask(frameEnergy, enterThreshold, exitThreshold, hangoverFrames);
|
|
456
|
+
|
|
457
|
+
// Step 5: Apply minimum active duration
|
|
458
|
+
if (minActiveFrames > 1) {
|
|
459
|
+
applyMinActiveDuration(activityMask, minActiveFrames);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Step 6: Build suppression mask
|
|
463
|
+
const suppressionMask = buildSuppressionMask(activityMask, suppressFrames);
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
frameEnergy,
|
|
467
|
+
noiseFloor,
|
|
468
|
+
enterThreshold,
|
|
469
|
+
exitThreshold,
|
|
470
|
+
activityMask,
|
|
471
|
+
suppressionMask,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Apply silence gating to onset novelty values.
|
|
477
|
+
*
|
|
478
|
+
* Zeros out novelty values where the activity mask is inactive
|
|
479
|
+
* or the suppression mask is active.
|
|
480
|
+
*
|
|
481
|
+
* @param novelty - Onset novelty values (modified in place)
|
|
482
|
+
* @param activityMask - Activity mask (1 = active)
|
|
483
|
+
* @param suppressionMask - Suppression mask (1 = suppress)
|
|
484
|
+
*/
|
|
485
|
+
export function applySilenceGating(
|
|
486
|
+
novelty: Float32Array,
|
|
487
|
+
activityMask: Uint8Array,
|
|
488
|
+
suppressionMask: Uint8Array
|
|
489
|
+
): void {
|
|
490
|
+
const nFrames = novelty.length;
|
|
491
|
+
|
|
492
|
+
for (let t = 0; t < nFrames; t++) {
|
|
493
|
+
const isActive = (activityMask[t] ?? 0) === 1;
|
|
494
|
+
const isSuppressed = (suppressionMask[t] ?? 0) === 1;
|
|
495
|
+
|
|
496
|
+
if (!isActive || isSuppressed) {
|
|
497
|
+
novelty[t] = 0;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Compute bin floor threshold for bin-level gating.
|
|
504
|
+
*
|
|
505
|
+
* @param frameEnergy - Energy of the current frame
|
|
506
|
+
* @param binFloorRel - Relative floor as fraction of frame energy
|
|
507
|
+
* @returns Absolute bin floor threshold
|
|
508
|
+
*/
|
|
509
|
+
export function computeBinFloor(frameEnergy: number, binFloorRel: number): number {
|
|
510
|
+
return frameEnergy * binFloorRel;
|
|
511
|
+
}
|
package/src/dsp/spectral.ts
CHANGED
|
@@ -1,21 +1,116 @@
|
|
|
1
1
|
import type { Spectrogram } from "./spectrogram";
|
|
2
|
+
import type { ActivitySignal } from "./activity";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Options for activity-aware spectral features.
|
|
6
|
+
*/
|
|
7
|
+
export type SpectralActivityOptions = {
|
|
8
|
+
/**
|
|
9
|
+
* Activity signal to use for gating.
|
|
10
|
+
* If provided, feature values will be zeroed in inactive regions.
|
|
11
|
+
*/
|
|
12
|
+
activity?: ActivitySignal;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Behavior for inactive frames.
|
|
16
|
+
* - "zero": Output 0 (default)
|
|
17
|
+
* - "hold": Hold the last active value
|
|
18
|
+
*/
|
|
19
|
+
inactiveBehavior?: "zero" | "hold";
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export type AmplitudeEnvelopeConfig = {
|
|
23
|
+
/** Hop size in samples (determines time resolution). Default: 512 */
|
|
24
|
+
hopSize?: number;
|
|
25
|
+
/** Window size for RMS calculation. Default: same as hopSize */
|
|
26
|
+
windowSize?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type AmplitudeEnvelopeResult = {
|
|
30
|
+
times: Float32Array;
|
|
31
|
+
values: Float32Array;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Amplitude envelope from raw audio samples.
|
|
36
|
+
*
|
|
37
|
+
* Computes RMS amplitude over windows of the time-domain signal.
|
|
38
|
+
* More efficient than spectrogram-based computation for full spectrum.
|
|
39
|
+
*
|
|
40
|
+
* @param samples - Mono audio samples
|
|
41
|
+
* @param sampleRate - Sample rate of the audio
|
|
42
|
+
* @param config - Configuration options
|
|
43
|
+
* @returns Times (seconds) and RMS amplitude values
|
|
44
|
+
*/
|
|
45
|
+
export function amplitudeEnvelope(
|
|
46
|
+
samples: Float32Array,
|
|
47
|
+
sampleRate: number,
|
|
48
|
+
config?: AmplitudeEnvelopeConfig
|
|
49
|
+
): AmplitudeEnvelopeResult {
|
|
50
|
+
const hopSize = config?.hopSize ?? 512;
|
|
51
|
+
const windowSize = config?.windowSize ?? hopSize;
|
|
52
|
+
|
|
53
|
+
const nFrames = Math.floor((samples.length - windowSize) / hopSize) + 1;
|
|
54
|
+
const times = new Float32Array(nFrames);
|
|
55
|
+
const values = new Float32Array(nFrames);
|
|
56
|
+
|
|
57
|
+
for (let t = 0; t < nFrames; t++) {
|
|
58
|
+
const start = t * hopSize;
|
|
59
|
+
const end = Math.min(start + windowSize, samples.length);
|
|
60
|
+
|
|
61
|
+
// RMS amplitude
|
|
62
|
+
let sumSq = 0;
|
|
63
|
+
for (let i = start; i < end; i++) {
|
|
64
|
+
const s = samples[i] ?? 0;
|
|
65
|
+
sumSq += s * s;
|
|
66
|
+
}
|
|
67
|
+
const rms = Math.sqrt(sumSq / (end - start));
|
|
68
|
+
|
|
69
|
+
times[t] = (start + windowSize / 2) / sampleRate;
|
|
70
|
+
values[t] = rms;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { times, values };
|
|
74
|
+
}
|
|
2
75
|
|
|
3
76
|
/**
|
|
4
77
|
* Spectral centroid per frame (Hz).
|
|
5
78
|
*
|
|
6
79
|
* Output is aligned 1:1 with `spec.times`.
|
|
80
|
+
*
|
|
81
|
+
* When activity gating is enabled:
|
|
82
|
+
* - Inactive frames output 0 (or hold last valid value if configured)
|
|
83
|
+
* - This prevents noise-induced centroid values during silence
|
|
84
|
+
*
|
|
85
|
+
* @param spec - Input spectrogram
|
|
86
|
+
* @param options - Activity gating options
|
|
7
87
|
*/
|
|
8
|
-
export function spectralCentroid(
|
|
88
|
+
export function spectralCentroid(
|
|
89
|
+
spec: Spectrogram,
|
|
90
|
+
options?: SpectralActivityOptions
|
|
91
|
+
): Float32Array {
|
|
9
92
|
const nFrames = spec.times.length;
|
|
10
93
|
const out = new Float32Array(nFrames);
|
|
11
94
|
|
|
12
95
|
const nBins = (spec.fftSize >>> 1) + 1;
|
|
13
96
|
const binHz = spec.sampleRate / spec.fftSize;
|
|
14
97
|
|
|
98
|
+
const activity = options?.activity;
|
|
99
|
+
const holdBehavior = options?.inactiveBehavior === "hold";
|
|
100
|
+
let lastActiveValue = 0;
|
|
101
|
+
|
|
15
102
|
for (let t = 0; t < nFrames; t++) {
|
|
103
|
+
// Check activity if provided
|
|
104
|
+
const isActive = activity ? (activity.isActive[t] ?? 0) === 1 : true;
|
|
105
|
+
|
|
106
|
+
if (!isActive) {
|
|
107
|
+
out[t] = holdBehavior ? lastActiveValue : 0;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
16
111
|
const mags = spec.magnitudes[t];
|
|
17
112
|
if (!mags) {
|
|
18
|
-
out[t] = 0;
|
|
113
|
+
out[t] = holdBehavior ? lastActiveValue : 0;
|
|
19
114
|
continue;
|
|
20
115
|
}
|
|
21
116
|
|
|
@@ -30,7 +125,9 @@ export function spectralCentroid(spec: Spectrogram): Float32Array {
|
|
|
30
125
|
den += m;
|
|
31
126
|
}
|
|
32
127
|
|
|
33
|
-
|
|
128
|
+
const centroid = den > 0 ? num / den : 0;
|
|
129
|
+
out[t] = centroid;
|
|
130
|
+
lastActiveValue = centroid;
|
|
34
131
|
}
|
|
35
132
|
|
|
36
133
|
return out;
|
|
@@ -44,16 +141,39 @@ export function spectralCentroid(spec: Spectrogram): Float32Array {
|
|
|
44
141
|
* - First frame flux is 0.
|
|
45
142
|
*
|
|
46
143
|
* Output is aligned 1:1 with `spec.times`.
|
|
144
|
+
*
|
|
145
|
+
* When activity gating is enabled:
|
|
146
|
+
* - Inactive frames output 0
|
|
147
|
+
* - Post-silence suppression is applied (first active frames after silence are zeroed)
|
|
148
|
+
* - This prevents false flux spikes at silence/sound boundaries
|
|
149
|
+
*
|
|
150
|
+
* @param spec - Input spectrogram
|
|
151
|
+
* @param options - Activity gating options
|
|
47
152
|
*/
|
|
48
|
-
export function spectralFlux(
|
|
153
|
+
export function spectralFlux(
|
|
154
|
+
spec: Spectrogram,
|
|
155
|
+
options?: SpectralActivityOptions
|
|
156
|
+
): Float32Array {
|
|
49
157
|
const nFrames = spec.times.length;
|
|
50
158
|
const out = new Float32Array(nFrames);
|
|
51
159
|
|
|
52
160
|
const nBins = (spec.fftSize >>> 1) + 1;
|
|
161
|
+
const activity = options?.activity;
|
|
53
162
|
|
|
54
163
|
let prev: Float32Array | null = null;
|
|
55
164
|
|
|
56
165
|
for (let t = 0; t < nFrames; t++) {
|
|
166
|
+
// Check activity and suppression if provided
|
|
167
|
+
const isActive = activity ? (activity.isActive[t] ?? 0) === 1 : true;
|
|
168
|
+
const isSuppressed = activity ? (activity.suppressMask[t] ?? 0) === 1 : false;
|
|
169
|
+
|
|
170
|
+
if (!isActive || isSuppressed) {
|
|
171
|
+
out[t] = 0;
|
|
172
|
+
// Reset prev to avoid spurious flux when activity resumes
|
|
173
|
+
prev = null;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
57
177
|
const mags = spec.magnitudes[t];
|
|
58
178
|
if (!mags) {
|
|
59
179
|
out[t] = 0;
|