@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
package/src/dsp/frequencyBand.ts
CHANGED
|
@@ -176,7 +176,7 @@ export function validateBandStructure(structure: FrequencyBandStructure): string
|
|
|
176
176
|
export function createBandStructure(): FrequencyBandStructure {
|
|
177
177
|
const now = new Date().toISOString();
|
|
178
178
|
return {
|
|
179
|
-
version:
|
|
179
|
+
version: 2,
|
|
180
180
|
bands: [],
|
|
181
181
|
createdAt: now,
|
|
182
182
|
modifiedAt: now,
|
|
@@ -202,12 +202,15 @@ export function createConstantBand(
|
|
|
202
202
|
enabled?: boolean;
|
|
203
203
|
sortOrder?: number;
|
|
204
204
|
id?: string;
|
|
205
|
+
/** The audio source this band belongs to. Defaults to "mixdown". */
|
|
206
|
+
sourceId?: string;
|
|
205
207
|
}
|
|
206
208
|
): FrequencyBand {
|
|
207
209
|
const now = new Date().toISOString();
|
|
208
210
|
return {
|
|
209
211
|
id: options?.id ?? generateBandId(),
|
|
210
212
|
label,
|
|
213
|
+
sourceId: options?.sourceId ?? "mixdown",
|
|
211
214
|
enabled: options?.enabled ?? true,
|
|
212
215
|
timeScope: { kind: "global" },
|
|
213
216
|
frequencyShape: [
|
|
@@ -249,12 +252,15 @@ export function createSectionedBand(
|
|
|
249
252
|
enabled?: boolean;
|
|
250
253
|
sortOrder?: number;
|
|
251
254
|
id?: string;
|
|
255
|
+
/** The audio source this band belongs to. Defaults to "mixdown". */
|
|
256
|
+
sourceId?: string;
|
|
252
257
|
}
|
|
253
258
|
): FrequencyBand {
|
|
254
259
|
const now = new Date().toISOString();
|
|
255
260
|
return {
|
|
256
261
|
id: options?.id ?? generateBandId(),
|
|
257
262
|
label,
|
|
263
|
+
sourceId: options?.sourceId ?? "mixdown",
|
|
258
264
|
enabled: options?.enabled ?? true,
|
|
259
265
|
timeScope: { kind: "sectioned", startTime, endTime },
|
|
260
266
|
frequencyShape: [
|
|
@@ -287,9 +293,10 @@ export function createSectionedBand(
|
|
|
287
293
|
* - Highs: 4000-20000 Hz
|
|
288
294
|
*
|
|
289
295
|
* @param duration - Track duration in seconds
|
|
296
|
+
* @param sourceId - The audio source these bands belong to. Defaults to "mixdown".
|
|
290
297
|
* @returns Array of FrequencyBand objects
|
|
291
298
|
*/
|
|
292
|
-
export function createStandardBands(duration: number): FrequencyBand[] {
|
|
299
|
+
export function createStandardBands(duration: number, sourceId: string = "mixdown"): FrequencyBand[] {
|
|
293
300
|
const now = new Date().toISOString();
|
|
294
301
|
const bands: Array<{ label: string; lowHz: number; highHz: number }> = [
|
|
295
302
|
{ label: "Sub Bass", lowHz: 20, highHz: 60 },
|
|
@@ -303,6 +310,7 @@ export function createStandardBands(duration: number): FrequencyBand[] {
|
|
|
303
310
|
return bands.map((b, index) => ({
|
|
304
311
|
id: generateBandId(),
|
|
305
312
|
label: b.label,
|
|
313
|
+
sourceId,
|
|
306
314
|
enabled: true,
|
|
307
315
|
timeScope: { kind: "global" } as FrequencyBandTimeScope,
|
|
308
316
|
frequencyShape: [
|
|
@@ -328,6 +336,38 @@ export function createStandardBands(duration: number): FrequencyBand[] {
|
|
|
328
336
|
// Query Helpers
|
|
329
337
|
// ----------------------------
|
|
330
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Get all bands belonging to a specific audio source.
|
|
341
|
+
*
|
|
342
|
+
* @param structure - Frequency band structure (can be null)
|
|
343
|
+
* @param sourceId - The audio source ID ("mixdown" or stem ID)
|
|
344
|
+
* @returns Array of bands for that source, sorted by sortOrder
|
|
345
|
+
*/
|
|
346
|
+
export function bandsForSource(
|
|
347
|
+
structure: FrequencyBandStructure | null,
|
|
348
|
+
sourceId: string
|
|
349
|
+
): FrequencyBand[] {
|
|
350
|
+
if (!structure) return [];
|
|
351
|
+
return sortBands(structure.bands.filter((band) => band.sourceId === sourceId));
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get all enabled bands belonging to a specific audio source.
|
|
356
|
+
*
|
|
357
|
+
* @param structure - Frequency band structure (can be null)
|
|
358
|
+
* @param sourceId - The audio source ID ("mixdown" or stem ID)
|
|
359
|
+
* @returns Array of enabled bands for that source, sorted by sortOrder
|
|
360
|
+
*/
|
|
361
|
+
export function enabledBandsForSource(
|
|
362
|
+
structure: FrequencyBandStructure | null,
|
|
363
|
+
sourceId: string
|
|
364
|
+
): FrequencyBand[] {
|
|
365
|
+
if (!structure) return [];
|
|
366
|
+
return sortBands(
|
|
367
|
+
structure.bands.filter((band) => band.sourceId === sourceId && band.enabled)
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
331
371
|
/**
|
|
332
372
|
* Get all bands active at a given time.
|
|
333
373
|
*
|
|
@@ -337,16 +377,19 @@ export function createStandardBands(duration: number): FrequencyBand[] {
|
|
|
337
377
|
*
|
|
338
378
|
* @param structure - Frequency band structure (can be null)
|
|
339
379
|
* @param time - Time in seconds
|
|
380
|
+
* @param sourceId - Optional: filter to a specific audio source
|
|
340
381
|
* @returns Array of active bands
|
|
341
382
|
*/
|
|
342
383
|
export function bandsActiveAt(
|
|
343
384
|
structure: FrequencyBandStructure | null,
|
|
344
|
-
time: number
|
|
385
|
+
time: number,
|
|
386
|
+
sourceId?: string
|
|
345
387
|
): FrequencyBand[] {
|
|
346
388
|
if (!structure) return [];
|
|
347
389
|
|
|
348
390
|
return structure.bands.filter((band) => {
|
|
349
391
|
if (!band.enabled) return false;
|
|
392
|
+
if (sourceId !== undefined && band.sourceId !== sourceId) return false;
|
|
350
393
|
if (band.timeScope.kind === "global") return true;
|
|
351
394
|
return time >= band.timeScope.startTime && time < band.timeScope.endTime;
|
|
352
395
|
});
|
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peak Picking Algorithm
|
|
3
|
+
*
|
|
4
|
+
* Detects local maxima in a 1D signal that exceed a threshold.
|
|
5
|
+
* Used to create event streams from continuous signals.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface PeakPickingParams {
|
|
9
|
+
/** Minimum normalized value (0-1) for a peak to be considered. Default: 0.3 */
|
|
10
|
+
threshold: number;
|
|
11
|
+
/** Minimum time between peaks in seconds. Default: 0.1 */
|
|
12
|
+
minDistance: number;
|
|
13
|
+
/** Number of samples to look back for local max comparison. Default: 2 */
|
|
14
|
+
preMax?: number;
|
|
15
|
+
/** Number of samples to look forward for local max comparison. Default: 2 */
|
|
16
|
+
postMax?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PeakPickingResult {
|
|
20
|
+
/** Times of detected peaks (seconds) */
|
|
21
|
+
times: Float32Array;
|
|
22
|
+
/** Normalized strength of each peak (0-1, based on normalized value) */
|
|
23
|
+
strengths: Float32Array;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Default peak picking parameters
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_PEAK_PICKING_PARAMS: Required<PeakPickingParams> = {
|
|
30
|
+
threshold: 0.3,
|
|
31
|
+
minDistance: 0.1,
|
|
32
|
+
preMax: 2,
|
|
33
|
+
postMax: 2,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Pick peaks from a continuous signal.
|
|
38
|
+
*
|
|
39
|
+
* @param times - Time values in seconds (Float32Array)
|
|
40
|
+
* @param values - Signal values (Float32Array)
|
|
41
|
+
* @param params - Peak picking parameters
|
|
42
|
+
* @returns Detected peaks with times and strengths
|
|
43
|
+
*/
|
|
44
|
+
export function pickPeaks(
|
|
45
|
+
times: Float32Array,
|
|
46
|
+
values: Float32Array,
|
|
47
|
+
params: Partial<PeakPickingParams> = {}
|
|
48
|
+
): PeakPickingResult {
|
|
49
|
+
const {
|
|
50
|
+
threshold,
|
|
51
|
+
minDistance,
|
|
52
|
+
preMax,
|
|
53
|
+
postMax,
|
|
54
|
+
} = { ...DEFAULT_PEAK_PICKING_PARAMS, ...params };
|
|
55
|
+
|
|
56
|
+
if (times.length === 0 || values.length === 0) {
|
|
57
|
+
return {
|
|
58
|
+
times: new Float32Array(0),
|
|
59
|
+
strengths: new Float32Array(0),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Find min/max for normalization
|
|
64
|
+
let minVal = values[0]!;
|
|
65
|
+
let maxVal = values[0]!;
|
|
66
|
+
for (let i = 1; i < values.length; i++) {
|
|
67
|
+
const v = values[i]!;
|
|
68
|
+
if (v < minVal) minVal = v;
|
|
69
|
+
if (v > maxVal) maxVal = v;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const range = maxVal - minVal;
|
|
73
|
+
if (range <= 0) {
|
|
74
|
+
// Constant signal - no peaks
|
|
75
|
+
return {
|
|
76
|
+
times: new Float32Array(0),
|
|
77
|
+
strengths: new Float32Array(0),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Normalize function
|
|
82
|
+
const normalize = (v: number) => (v - minVal) / range;
|
|
83
|
+
|
|
84
|
+
// Find peaks
|
|
85
|
+
const peakIndices: number[] = [];
|
|
86
|
+
const peakStrengths: number[] = [];
|
|
87
|
+
|
|
88
|
+
for (let i = preMax; i < values.length - postMax; i++) {
|
|
89
|
+
const currentVal = values[i]!;
|
|
90
|
+
const normalizedVal = normalize(currentVal);
|
|
91
|
+
|
|
92
|
+
// Skip if below threshold
|
|
93
|
+
if (normalizedVal < threshold) continue;
|
|
94
|
+
|
|
95
|
+
// Check if local maximum
|
|
96
|
+
let isMax = true;
|
|
97
|
+
|
|
98
|
+
// Check pre-samples
|
|
99
|
+
for (let j = 1; j <= preMax; j++) {
|
|
100
|
+
if (values[i - j]! >= currentVal) {
|
|
101
|
+
isMax = false;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check post-samples
|
|
107
|
+
if (isMax) {
|
|
108
|
+
for (let j = 1; j <= postMax; j++) {
|
|
109
|
+
if (values[i + j]! > currentVal) {
|
|
110
|
+
isMax = false;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (isMax) {
|
|
117
|
+
peakIndices.push(i);
|
|
118
|
+
peakStrengths.push(normalizedVal);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Apply minimum distance constraint (keep higher peaks)
|
|
123
|
+
const filteredIndices: number[] = [];
|
|
124
|
+
const filteredStrengths: number[] = [];
|
|
125
|
+
|
|
126
|
+
for (let i = 0; i < peakIndices.length; i++) {
|
|
127
|
+
const idx = peakIndices[i]!;
|
|
128
|
+
const time = times[idx]!;
|
|
129
|
+
const strength = peakStrengths[i]!;
|
|
130
|
+
|
|
131
|
+
// Check if there's a higher peak within minDistance
|
|
132
|
+
let shouldKeep = true;
|
|
133
|
+
|
|
134
|
+
for (let j = 0; j < filteredIndices.length; j++) {
|
|
135
|
+
const prevIdx = filteredIndices[j]!;
|
|
136
|
+
const prevTime = times[prevIdx]!;
|
|
137
|
+
const prevStrength = filteredStrengths[j]!;
|
|
138
|
+
|
|
139
|
+
if (Math.abs(time - prevTime) < minDistance) {
|
|
140
|
+
if (strength > prevStrength) {
|
|
141
|
+
// Replace previous peak
|
|
142
|
+
filteredIndices[j] = idx;
|
|
143
|
+
filteredStrengths[j] = strength;
|
|
144
|
+
}
|
|
145
|
+
shouldKeep = false;
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (shouldKeep) {
|
|
151
|
+
filteredIndices.push(idx);
|
|
152
|
+
filteredStrengths.push(strength);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Convert to output format
|
|
157
|
+
const resultTimes = new Float32Array(filteredIndices.length);
|
|
158
|
+
const resultStrengths = new Float32Array(filteredStrengths.length);
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i < filteredIndices.length; i++) {
|
|
161
|
+
resultTimes[i] = times[filteredIndices[i]!]!;
|
|
162
|
+
resultStrengths[i] = filteredStrengths[i]!;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return {
|
|
166
|
+
times: resultTimes,
|
|
167
|
+
strengths: resultStrengths,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Result from adaptive peak picking, including the threshold curve for visualization.
|
|
173
|
+
*/
|
|
174
|
+
export interface AdaptivePeakPickingResult extends PeakPickingResult {
|
|
175
|
+
/** The adaptive threshold curve (normalized 0-1) for visualization */
|
|
176
|
+
thresholdCurve: Float32Array;
|
|
177
|
+
/** Time values corresponding to threshold curve samples */
|
|
178
|
+
thresholdTimes: Float32Array;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Compute the adaptive threshold curve for visualization.
|
|
183
|
+
* This is the normalized threshold that would be used by pickPeaksAdaptive.
|
|
184
|
+
*
|
|
185
|
+
* @param values - Signal values (Float32Array)
|
|
186
|
+
* @param windowSize - Window size for local statistics (in samples)
|
|
187
|
+
* @param thresholdMultiplier - Multiplier for local std (default 1.5)
|
|
188
|
+
* @returns Normalized threshold curve (0-1 range)
|
|
189
|
+
*/
|
|
190
|
+
export function computeAdaptiveThreshold(
|
|
191
|
+
values: Float32Array,
|
|
192
|
+
windowSize: number = 20,
|
|
193
|
+
thresholdMultiplier: number = 1.5
|
|
194
|
+
): Float32Array {
|
|
195
|
+
if (values.length === 0) {
|
|
196
|
+
return new Float32Array(0);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Find min/max for normalization
|
|
200
|
+
let minVal = values[0]!;
|
|
201
|
+
let maxVal = values[0]!;
|
|
202
|
+
for (let i = 1; i < values.length; i++) {
|
|
203
|
+
const v = values[i]!;
|
|
204
|
+
if (v < minVal) minVal = v;
|
|
205
|
+
if (v > maxVal) maxVal = v;
|
|
206
|
+
}
|
|
207
|
+
const range = maxVal - minVal;
|
|
208
|
+
if (range <= 0) {
|
|
209
|
+
return new Float32Array(values.length).fill(0.5);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Compute adaptive threshold
|
|
213
|
+
const halfWindow = Math.floor(windowSize / 2);
|
|
214
|
+
const thresholdCurve = new Float32Array(values.length);
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < values.length; i++) {
|
|
217
|
+
const start = Math.max(0, i - halfWindow);
|
|
218
|
+
const end = Math.min(values.length, i + halfWindow + 1);
|
|
219
|
+
const windowLen = end - start;
|
|
220
|
+
|
|
221
|
+
// Compute local mean
|
|
222
|
+
let sum = 0;
|
|
223
|
+
for (let j = start; j < end; j++) {
|
|
224
|
+
sum += values[j]!;
|
|
225
|
+
}
|
|
226
|
+
const mean = sum / windowLen;
|
|
227
|
+
|
|
228
|
+
// Compute local std
|
|
229
|
+
let sumSq = 0;
|
|
230
|
+
for (let j = start; j < end; j++) {
|
|
231
|
+
const diff = values[j]! - mean;
|
|
232
|
+
sumSq += diff * diff;
|
|
233
|
+
}
|
|
234
|
+
const std = Math.sqrt(sumSq / windowLen);
|
|
235
|
+
|
|
236
|
+
// Adaptive threshold in original scale
|
|
237
|
+
const adaptiveThresh = mean + thresholdMultiplier * std;
|
|
238
|
+
|
|
239
|
+
// Normalize to 0-1 range
|
|
240
|
+
thresholdCurve[i] = Math.max(0, Math.min(1, (adaptiveThresh - minVal) / range));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return thresholdCurve;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Pick peaks with adaptive threshold based on local statistics.
|
|
248
|
+
*
|
|
249
|
+
* @param times - Time values in seconds
|
|
250
|
+
* @param values - Signal values
|
|
251
|
+
* @param windowSize - Window size for local statistics (in samples)
|
|
252
|
+
* @param params - Base peak picking parameters (threshold used as multiplier of local std)
|
|
253
|
+
* @param includeThresholdCurve - If true, returns the threshold curve for visualization
|
|
254
|
+
*/
|
|
255
|
+
export function pickPeaksAdaptive(
|
|
256
|
+
times: Float32Array,
|
|
257
|
+
values: Float32Array,
|
|
258
|
+
windowSize: number = 20,
|
|
259
|
+
params: Partial<PeakPickingParams> = {},
|
|
260
|
+
includeThresholdCurve: boolean = false
|
|
261
|
+
): PeakPickingResult | AdaptivePeakPickingResult {
|
|
262
|
+
const {
|
|
263
|
+
threshold,
|
|
264
|
+
minDistance,
|
|
265
|
+
preMax,
|
|
266
|
+
postMax,
|
|
267
|
+
} = { ...DEFAULT_PEAK_PICKING_PARAMS, threshold: 1.5, ...params };
|
|
268
|
+
|
|
269
|
+
if (times.length === 0 || values.length === 0) {
|
|
270
|
+
return {
|
|
271
|
+
times: new Float32Array(0),
|
|
272
|
+
strengths: new Float32Array(0),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Compute adaptive threshold using local mean + threshold * local std
|
|
277
|
+
const adaptiveThreshold = new Float32Array(values.length);
|
|
278
|
+
const halfWindow = Math.floor(windowSize / 2);
|
|
279
|
+
|
|
280
|
+
for (let i = 0; i < values.length; i++) {
|
|
281
|
+
const start = Math.max(0, i - halfWindow);
|
|
282
|
+
const end = Math.min(values.length, i + halfWindow + 1);
|
|
283
|
+
const windowLen = end - start;
|
|
284
|
+
|
|
285
|
+
// Compute local mean
|
|
286
|
+
let sum = 0;
|
|
287
|
+
for (let j = start; j < end; j++) {
|
|
288
|
+
sum += values[j]!;
|
|
289
|
+
}
|
|
290
|
+
const mean = sum / windowLen;
|
|
291
|
+
|
|
292
|
+
// Compute local std
|
|
293
|
+
let sumSq = 0;
|
|
294
|
+
for (let j = start; j < end; j++) {
|
|
295
|
+
const diff = values[j]! - mean;
|
|
296
|
+
sumSq += diff * diff;
|
|
297
|
+
}
|
|
298
|
+
const std = Math.sqrt(sumSq / windowLen);
|
|
299
|
+
|
|
300
|
+
adaptiveThreshold[i] = mean + threshold * std;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Find min/max for strength normalization
|
|
304
|
+
let minVal = values[0]!;
|
|
305
|
+
let maxVal = values[0]!;
|
|
306
|
+
for (let i = 1; i < values.length; i++) {
|
|
307
|
+
const v = values[i]!;
|
|
308
|
+
if (v < minVal) minVal = v;
|
|
309
|
+
if (v > maxVal) maxVal = v;
|
|
310
|
+
}
|
|
311
|
+
const range = maxVal - minVal;
|
|
312
|
+
const normalize = range > 0 ? (v: number) => (v - minVal) / range : () => 0.5;
|
|
313
|
+
|
|
314
|
+
// Find peaks
|
|
315
|
+
const peakIndices: number[] = [];
|
|
316
|
+
const peakStrengths: number[] = [];
|
|
317
|
+
|
|
318
|
+
for (let i = preMax; i < values.length - postMax; i++) {
|
|
319
|
+
const currentVal = values[i]!;
|
|
320
|
+
|
|
321
|
+
// Skip if below adaptive threshold
|
|
322
|
+
if (currentVal < adaptiveThreshold[i]!) continue;
|
|
323
|
+
|
|
324
|
+
// Check if local maximum
|
|
325
|
+
let isMax = true;
|
|
326
|
+
|
|
327
|
+
for (let j = 1; j <= preMax; j++) {
|
|
328
|
+
if (values[i - j]! >= currentVal) {
|
|
329
|
+
isMax = false;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (isMax) {
|
|
335
|
+
for (let j = 1; j <= postMax; j++) {
|
|
336
|
+
if (values[i + j]! > currentVal) {
|
|
337
|
+
isMax = false;
|
|
338
|
+
break;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (isMax) {
|
|
344
|
+
peakIndices.push(i);
|
|
345
|
+
peakStrengths.push(normalize(currentVal));
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Apply minimum distance constraint
|
|
350
|
+
const filteredIndices: number[] = [];
|
|
351
|
+
const filteredStrengths: number[] = [];
|
|
352
|
+
|
|
353
|
+
for (let i = 0; i < peakIndices.length; i++) {
|
|
354
|
+
const idx = peakIndices[i]!;
|
|
355
|
+
const time = times[idx]!;
|
|
356
|
+
const strength = peakStrengths[i]!;
|
|
357
|
+
|
|
358
|
+
let shouldKeep = true;
|
|
359
|
+
|
|
360
|
+
for (let j = 0; j < filteredIndices.length; j++) {
|
|
361
|
+
const prevIdx = filteredIndices[j]!;
|
|
362
|
+
const prevTime = times[prevIdx]!;
|
|
363
|
+
const prevStrength = filteredStrengths[j]!;
|
|
364
|
+
|
|
365
|
+
if (Math.abs(time - prevTime) < minDistance) {
|
|
366
|
+
if (strength > prevStrength) {
|
|
367
|
+
filteredIndices[j] = idx;
|
|
368
|
+
filteredStrengths[j] = strength;
|
|
369
|
+
}
|
|
370
|
+
shouldKeep = false;
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (shouldKeep) {
|
|
376
|
+
filteredIndices.push(idx);
|
|
377
|
+
filteredStrengths.push(strength);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Convert to output format
|
|
382
|
+
const resultTimes = new Float32Array(filteredIndices.length);
|
|
383
|
+
const resultStrengths = new Float32Array(filteredStrengths.length);
|
|
384
|
+
|
|
385
|
+
for (let i = 0; i < filteredIndices.length; i++) {
|
|
386
|
+
resultTimes[i] = times[filteredIndices[i]!]!;
|
|
387
|
+
resultStrengths[i] = filteredStrengths[i]!;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const baseResult: PeakPickingResult = {
|
|
391
|
+
times: resultTimes,
|
|
392
|
+
strengths: resultStrengths,
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
if (!includeThresholdCurve) {
|
|
396
|
+
return baseResult;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Compute normalized threshold curve for visualization
|
|
400
|
+
const normalizedThreshold = new Float32Array(values.length);
|
|
401
|
+
for (let i = 0; i < values.length; i++) {
|
|
402
|
+
normalizedThreshold[i] = range > 0
|
|
403
|
+
? Math.max(0, Math.min(1, (adaptiveThreshold[i]! - minVal) / range))
|
|
404
|
+
: 0.5;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
...baseResult,
|
|
409
|
+
thresholdCurve: normalizedThreshold,
|
|
410
|
+
thresholdTimes: times,
|
|
411
|
+
} as AdaptivePeakPickingResult;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Hysteresis gate parameters for peak filtering.
|
|
416
|
+
*/
|
|
417
|
+
export interface HysteresisGateParams {
|
|
418
|
+
/** Upper threshold (0-1) - signal must exceed this to trigger "on" state */
|
|
419
|
+
onThreshold: number;
|
|
420
|
+
/** Lower threshold (0-1) - signal must fall below this to trigger "off" state */
|
|
421
|
+
offThreshold: number;
|
|
422
|
+
/** Minimum time between peaks in seconds */
|
|
423
|
+
minDistance: number;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Apply hysteresis gating to peaks.
|
|
428
|
+
* Peaks are only kept if the signal has dropped below offThreshold since the last peak.
|
|
429
|
+
* This prevents multiple triggers during sustained high-value regions.
|
|
430
|
+
*
|
|
431
|
+
* @param times - Time values of the original signal
|
|
432
|
+
* @param values - Values of the original signal (for checking hysteresis)
|
|
433
|
+
* @param peakTimes - Times of detected peaks
|
|
434
|
+
* @param peakStrengths - Strengths of detected peaks
|
|
435
|
+
* @param params - Hysteresis gate parameters
|
|
436
|
+
* @returns Filtered peaks after hysteresis gating
|
|
437
|
+
*/
|
|
438
|
+
export function applyHysteresisGate(
|
|
439
|
+
times: Float32Array,
|
|
440
|
+
values: Float32Array,
|
|
441
|
+
peakTimes: Float32Array,
|
|
442
|
+
peakStrengths: Float32Array,
|
|
443
|
+
params: HysteresisGateParams
|
|
444
|
+
): PeakPickingResult {
|
|
445
|
+
const { onThreshold, offThreshold, minDistance } = params;
|
|
446
|
+
|
|
447
|
+
if (peakTimes.length === 0) {
|
|
448
|
+
return { times: new Float32Array(0), strengths: new Float32Array(0) };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Normalize values to 0-1 range
|
|
452
|
+
let minVal = values[0]!;
|
|
453
|
+
let maxVal = values[0]!;
|
|
454
|
+
for (let i = 1; i < values.length; i++) {
|
|
455
|
+
const v = values[i]!;
|
|
456
|
+
if (v < minVal) minVal = v;
|
|
457
|
+
if (v > maxVal) maxVal = v;
|
|
458
|
+
}
|
|
459
|
+
const range = maxVal - minVal;
|
|
460
|
+
const normalize = range > 0 ? (v: number) => (v - minVal) / range : () => 0.5;
|
|
461
|
+
|
|
462
|
+
// Build time-to-index lookup (approximate)
|
|
463
|
+
const getIndexForTime = (t: number): number => {
|
|
464
|
+
// Binary search for closest time
|
|
465
|
+
let lo = 0;
|
|
466
|
+
let hi = times.length - 1;
|
|
467
|
+
while (lo < hi) {
|
|
468
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
469
|
+
if (times[mid]! < t) {
|
|
470
|
+
lo = mid + 1;
|
|
471
|
+
} else {
|
|
472
|
+
hi = mid;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return lo;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const filteredTimes: number[] = [];
|
|
479
|
+
const filteredStrengths: number[] = [];
|
|
480
|
+
let lastPeakTime = -Infinity;
|
|
481
|
+
let gateOpen = true; // Start with gate open
|
|
482
|
+
|
|
483
|
+
for (let i = 0; i < peakTimes.length; i++) {
|
|
484
|
+
const peakTime = peakTimes[i]!;
|
|
485
|
+
const peakStrength = peakStrengths[i]!;
|
|
486
|
+
|
|
487
|
+
// Check if we've met the minimum distance requirement
|
|
488
|
+
if (peakTime - lastPeakTime < minDistance) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// If gate is closed, check if signal has dropped below offThreshold since last peak
|
|
493
|
+
if (!gateOpen) {
|
|
494
|
+
const startIdx = getIndexForTime(lastPeakTime);
|
|
495
|
+
const endIdx = getIndexForTime(peakTime);
|
|
496
|
+
|
|
497
|
+
// Check if signal dropped below offThreshold at any point
|
|
498
|
+
for (let j = startIdx; j < endIdx && j < values.length; j++) {
|
|
499
|
+
if (normalize(values[j]!) < offThreshold) {
|
|
500
|
+
gateOpen = true;
|
|
501
|
+
break;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// If gate is open and peak exceeds onThreshold, keep it
|
|
507
|
+
if (gateOpen && peakStrength >= onThreshold) {
|
|
508
|
+
filteredTimes.push(peakTime);
|
|
509
|
+
filteredStrengths.push(peakStrength);
|
|
510
|
+
lastPeakTime = peakTime;
|
|
511
|
+
gateOpen = false; // Close gate after peak
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
return {
|
|
516
|
+
times: new Float32Array(filteredTimes),
|
|
517
|
+
strengths: new Float32Array(filteredStrengths),
|
|
518
|
+
};
|
|
519
|
+
}
|
package/src/dsp/spectral.ts
CHANGED
|
@@ -1,5 +1,59 @@
|
|
|
1
1
|
import type { Spectrogram } from "./spectrogram";
|
|
2
2
|
|
|
3
|
+
export type AmplitudeEnvelopeConfig = {
|
|
4
|
+
/** Hop size in samples (determines time resolution). Default: 512 */
|
|
5
|
+
hopSize?: number;
|
|
6
|
+
/** Window size for RMS calculation. Default: same as hopSize */
|
|
7
|
+
windowSize?: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type AmplitudeEnvelopeResult = {
|
|
11
|
+
times: Float32Array;
|
|
12
|
+
values: Float32Array;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Amplitude envelope from raw audio samples.
|
|
17
|
+
*
|
|
18
|
+
* Computes RMS amplitude over windows of the time-domain signal.
|
|
19
|
+
* More efficient than spectrogram-based computation for full spectrum.
|
|
20
|
+
*
|
|
21
|
+
* @param samples - Mono audio samples
|
|
22
|
+
* @param sampleRate - Sample rate of the audio
|
|
23
|
+
* @param config - Configuration options
|
|
24
|
+
* @returns Times (seconds) and RMS amplitude values
|
|
25
|
+
*/
|
|
26
|
+
export function amplitudeEnvelope(
|
|
27
|
+
samples: Float32Array,
|
|
28
|
+
sampleRate: number,
|
|
29
|
+
config?: AmplitudeEnvelopeConfig
|
|
30
|
+
): AmplitudeEnvelopeResult {
|
|
31
|
+
const hopSize = config?.hopSize ?? 512;
|
|
32
|
+
const windowSize = config?.windowSize ?? hopSize;
|
|
33
|
+
|
|
34
|
+
const nFrames = Math.floor((samples.length - windowSize) / hopSize) + 1;
|
|
35
|
+
const times = new Float32Array(nFrames);
|
|
36
|
+
const values = new Float32Array(nFrames);
|
|
37
|
+
|
|
38
|
+
for (let t = 0; t < nFrames; t++) {
|
|
39
|
+
const start = t * hopSize;
|
|
40
|
+
const end = Math.min(start + windowSize, samples.length);
|
|
41
|
+
|
|
42
|
+
// RMS amplitude
|
|
43
|
+
let sumSq = 0;
|
|
44
|
+
for (let i = start; i < end; i++) {
|
|
45
|
+
const s = samples[i] ?? 0;
|
|
46
|
+
sumSq += s * s;
|
|
47
|
+
}
|
|
48
|
+
const rms = Math.sqrt(sumSq / (end - start));
|
|
49
|
+
|
|
50
|
+
times[t] = (start + windowSize / 2) / sampleRate;
|
|
51
|
+
values[t] = rms;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { times, values };
|
|
55
|
+
}
|
|
56
|
+
|
|
3
57
|
/**
|
|
4
58
|
* Spectral centroid per frame (Hz).
|
|
5
59
|
*
|