@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.
@@ -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: 1,
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
+ }
@@ -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
  *