@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.
Files changed (47) hide show
  1. package/dist/chunk-HF3QHCRK.js +3234 -0
  2. package/dist/chunk-HF3QHCRK.js.map +1 -0
  3. package/dist/index.d.ts +2122 -47
  4. package/dist/index.js +3524 -39
  5. package/dist/index.js.map +1 -1
  6. package/dist/runMir-CnJQbBr8.d.ts +187 -0
  7. package/dist/runner/runMir.d.ts +2 -2
  8. package/dist/runner/runMir.js +1 -1
  9. package/dist/runner/workerProtocol.d.ts +8 -1
  10. package/dist/runner/workerProtocol.js.map +1 -1
  11. package/dist/types-DqH4umN8.d.ts +761 -0
  12. package/package.json +2 -2
  13. package/src/dsp/activity.ts +544 -0
  14. package/src/dsp/bandCqt.ts +662 -0
  15. package/src/dsp/bandEvents.ts +351 -0
  16. package/src/dsp/bandMask.ts +225 -0
  17. package/src/dsp/bandMir.ts +524 -0
  18. package/src/dsp/bandProposal.ts +552 -0
  19. package/src/dsp/beatCandidates.ts +299 -0
  20. package/src/dsp/cqt.ts +386 -0
  21. package/src/dsp/cqtSignals.ts +462 -0
  22. package/src/dsp/customSignalReduction.ts +841 -0
  23. package/src/dsp/eventToSignal.ts +531 -0
  24. package/src/dsp/frequencyBand.ts +956 -0
  25. package/src/dsp/mel.ts +56 -3
  26. package/src/dsp/musicalTime.ts +240 -0
  27. package/src/dsp/onset.ts +296 -30
  28. package/src/dsp/peakPicking.ts +519 -0
  29. package/src/dsp/phaseAlignment.ts +153 -0
  30. package/src/dsp/pitch.ts +289 -0
  31. package/src/dsp/resample.ts +44 -0
  32. package/src/dsp/signalTransforms.ts +660 -0
  33. package/src/dsp/silenceGating.ts +511 -0
  34. package/src/dsp/spectral.ts +124 -4
  35. package/src/dsp/tempoHypotheses.ts +395 -0
  36. package/src/gpu/bufferPool.ts +266 -0
  37. package/src/gpu/context.ts +30 -6
  38. package/src/gpu/helpers.ts +85 -0
  39. package/src/gpu/melProject.ts +83 -0
  40. package/src/index.ts +366 -5
  41. package/src/runner/runMir.ts +234 -2
  42. package/src/runner/workerProtocol.ts +9 -1
  43. package/src/types.ts +768 -3
  44. package/dist/chunk-DUWYCAVG.js +0 -1525
  45. package/dist/chunk-DUWYCAVG.js.map +0 -1
  46. package/dist/runMir-CSIBwNZ3.d.ts +0 -84
  47. package/dist/types-BE3py4fZ.d.ts +0 -83
@@ -0,0 +1,956 @@
1
+ /**
2
+ * Frequency Band utilities for F1.
3
+ *
4
+ * These functions provide validation, creation, and query operations
5
+ * for frequency band structures. All computations are deterministic and pure.
6
+ */
7
+
8
+ import type {
9
+ FrequencyBand,
10
+ FrequencyBandStructure,
11
+ FrequencyBandTimeScope,
12
+ FrequencyBandProvenance,
13
+ FrequencyBoundsAtTime,
14
+ FrequencySegment,
15
+ FrequencyKeyframe,
16
+ } from "../types";
17
+
18
+ // ----------------------------
19
+ // ID Generation
20
+ // ----------------------------
21
+
22
+ /**
23
+ * Generate a unique band ID.
24
+ */
25
+ export function generateBandId(): string {
26
+ return `band-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
27
+ }
28
+
29
+ // ----------------------------
30
+ // Validation
31
+ // ----------------------------
32
+
33
+ /**
34
+ * Validate frequency segments for a band.
35
+ *
36
+ * Checks:
37
+ * - lowHz < highHz at all segment boundaries
38
+ * - All frequency values are non-negative
39
+ * - startTime < endTime for each segment
40
+ * - Segments don't overlap in time
41
+ * - Segments are ordered by startTime
42
+ *
43
+ * @param segments - Frequency segments to validate
44
+ * @returns Array of validation errors (empty if valid)
45
+ */
46
+ export function validateFrequencySegments(segments: FrequencySegment[]): string[] {
47
+ const errors: string[] = [];
48
+
49
+ for (let i = 0; i < segments.length; i++) {
50
+ const seg = segments[i];
51
+ if (!seg) continue;
52
+
53
+ // Check frequency invariants at start
54
+ if (seg.lowHzStart < 0 || seg.highHzStart < 0) {
55
+ errors.push(`Segment ${i}: start frequencies must be non-negative`);
56
+ }
57
+ if (seg.lowHzStart >= seg.highHzStart) {
58
+ errors.push(
59
+ `Segment ${i}: lowHzStart (${seg.lowHzStart}) must be < highHzStart (${seg.highHzStart})`
60
+ );
61
+ }
62
+
63
+ // Check frequency invariants at end
64
+ if (seg.lowHzEnd < 0 || seg.highHzEnd < 0) {
65
+ errors.push(`Segment ${i}: end frequencies must be non-negative`);
66
+ }
67
+ if (seg.lowHzEnd >= seg.highHzEnd) {
68
+ errors.push(
69
+ `Segment ${i}: lowHzEnd (${seg.lowHzEnd}) must be < highHzEnd (${seg.highHzEnd})`
70
+ );
71
+ }
72
+
73
+ // Check time ordering
74
+ if (seg.startTime >= seg.endTime) {
75
+ errors.push(
76
+ `Segment ${i}: startTime (${seg.startTime}) must be < endTime (${seg.endTime})`
77
+ );
78
+ }
79
+
80
+ // Check non-overlap with next segment
81
+ if (i < segments.length - 1) {
82
+ const next = segments[i + 1];
83
+ if (!next) continue;
84
+ if (seg.endTime > next.startTime) {
85
+ errors.push(`Segment ${i} overlaps with segment ${i + 1}`);
86
+ }
87
+ if (seg.startTime >= next.startTime) {
88
+ errors.push(`Segment ${i} not ordered before segment ${i + 1}`);
89
+ }
90
+ }
91
+ }
92
+
93
+ return errors;
94
+ }
95
+
96
+ /**
97
+ * Validate a frequency band.
98
+ *
99
+ * @param band - Frequency band to validate
100
+ * @returns Array of validation errors (empty if valid)
101
+ */
102
+ export function validateFrequencyBand(band: FrequencyBand): string[] {
103
+ const errors: string[] = [];
104
+
105
+ if (!band.id || band.id.trim() === "") {
106
+ errors.push("Band must have a non-empty id");
107
+ }
108
+
109
+ if (!band.label || band.label.trim() === "") {
110
+ errors.push("Band must have a non-empty label");
111
+ }
112
+
113
+ if (band.frequencyShape.length === 0) {
114
+ errors.push(`Band "${band.label}": must have at least one frequency segment`);
115
+ }
116
+
117
+ // Validate segments
118
+ const segmentErrors = validateFrequencySegments(band.frequencyShape);
119
+ errors.push(...segmentErrors.map((e) => `Band "${band.label}": ${e}`));
120
+
121
+ // For sectioned bands, validate segments cover the scope
122
+ if (band.timeScope.kind === "sectioned" && band.frequencyShape.length > 0) {
123
+ const { startTime, endTime } = band.timeScope;
124
+ const first = band.frequencyShape[0];
125
+ const last = band.frequencyShape[band.frequencyShape.length - 1];
126
+
127
+ if (first && first.startTime > startTime) {
128
+ errors.push(
129
+ `Band "${band.label}": segments don't cover scope start (first segment starts at ${first.startTime}, scope starts at ${startTime})`
130
+ );
131
+ }
132
+ if (last && last.endTime < endTime) {
133
+ errors.push(
134
+ `Band "${band.label}": segments don't cover scope end (last segment ends at ${last.endTime}, scope ends at ${endTime})`
135
+ );
136
+ }
137
+ }
138
+ // Note: Global bands skip coverage validation per design decision
139
+
140
+ return errors;
141
+ }
142
+
143
+ /**
144
+ * Validate a complete frequency band structure.
145
+ *
146
+ * @param structure - Structure to validate
147
+ * @returns Array of validation errors (empty if valid)
148
+ */
149
+ export function validateBandStructure(structure: FrequencyBandStructure): string[] {
150
+ const errors: string[] = [];
151
+
152
+ // Check for duplicate IDs
153
+ const ids = new Set<string>();
154
+ for (const band of structure.bands) {
155
+ if (ids.has(band.id)) {
156
+ errors.push(`Duplicate band ID: ${band.id}`);
157
+ }
158
+ ids.add(band.id);
159
+ }
160
+
161
+ // Validate each band
162
+ for (const band of structure.bands) {
163
+ errors.push(...validateFrequencyBand(band));
164
+ }
165
+
166
+ return errors;
167
+ }
168
+
169
+ // ----------------------------
170
+ // Creation Helpers
171
+ // ----------------------------
172
+
173
+ /**
174
+ * Create an empty FrequencyBandStructure.
175
+ */
176
+ export function createBandStructure(): FrequencyBandStructure {
177
+ const now = new Date().toISOString();
178
+ return {
179
+ version: 2,
180
+ bands: [],
181
+ createdAt: now,
182
+ modifiedAt: now,
183
+ };
184
+ }
185
+
186
+ /**
187
+ * Create a simple constant-frequency band (no time variation).
188
+ *
189
+ * @param label - Human-readable band label
190
+ * @param lowHz - Lower frequency bound in Hz
191
+ * @param highHz - Upper frequency bound in Hz
192
+ * @param duration - Duration in seconds (defines segment end time)
193
+ * @param options - Optional configuration
194
+ * @returns A new FrequencyBand
195
+ */
196
+ export function createConstantBand(
197
+ label: string,
198
+ lowHz: number,
199
+ highHz: number,
200
+ duration: number,
201
+ options?: {
202
+ enabled?: boolean;
203
+ sortOrder?: number;
204
+ id?: string;
205
+ /** The audio source this band belongs to. Defaults to "mixdown". */
206
+ sourceId?: string;
207
+ }
208
+ ): FrequencyBand {
209
+ const now = new Date().toISOString();
210
+ return {
211
+ id: options?.id ?? generateBandId(),
212
+ label,
213
+ sourceId: options?.sourceId ?? "mixdown",
214
+ enabled: options?.enabled ?? true,
215
+ timeScope: { kind: "global" },
216
+ frequencyShape: [
217
+ {
218
+ startTime: 0,
219
+ endTime: duration,
220
+ lowHzStart: lowHz,
221
+ highHzStart: highHz,
222
+ lowHzEnd: lowHz,
223
+ highHzEnd: highHz,
224
+ },
225
+ ],
226
+ sortOrder: options?.sortOrder ?? 0,
227
+ provenance: {
228
+ source: "manual",
229
+ createdAt: now,
230
+ },
231
+ };
232
+ }
233
+
234
+ /**
235
+ * Create a sectioned band (applies only to a time range).
236
+ *
237
+ * @param label - Human-readable band label
238
+ * @param lowHz - Lower frequency bound in Hz
239
+ * @param highHz - Upper frequency bound in Hz
240
+ * @param startTime - Section start time in seconds
241
+ * @param endTime - Section end time in seconds
242
+ * @param options - Optional configuration
243
+ * @returns A new FrequencyBand
244
+ */
245
+ export function createSectionedBand(
246
+ label: string,
247
+ lowHz: number,
248
+ highHz: number,
249
+ startTime: number,
250
+ endTime: number,
251
+ options?: {
252
+ enabled?: boolean;
253
+ sortOrder?: number;
254
+ id?: string;
255
+ /** The audio source this band belongs to. Defaults to "mixdown". */
256
+ sourceId?: string;
257
+ }
258
+ ): FrequencyBand {
259
+ const now = new Date().toISOString();
260
+ return {
261
+ id: options?.id ?? generateBandId(),
262
+ label,
263
+ sourceId: options?.sourceId ?? "mixdown",
264
+ enabled: options?.enabled ?? true,
265
+ timeScope: { kind: "sectioned", startTime, endTime },
266
+ frequencyShape: [
267
+ {
268
+ startTime,
269
+ endTime,
270
+ lowHzStart: lowHz,
271
+ highHzStart: highHz,
272
+ lowHzEnd: lowHz,
273
+ highHzEnd: highHz,
274
+ },
275
+ ],
276
+ sortOrder: options?.sortOrder ?? 0,
277
+ provenance: {
278
+ source: "manual",
279
+ createdAt: now,
280
+ },
281
+ };
282
+ }
283
+
284
+ /**
285
+ * Create standard frequency bands for a track.
286
+ *
287
+ * Creates a standard 6-band frequency split:
288
+ * - Sub Bass: 20-60 Hz
289
+ * - Bass: 60-250 Hz
290
+ * - Low Mids: 250-500 Hz
291
+ * - Mids: 500-2000 Hz
292
+ * - High Mids: 2000-4000 Hz
293
+ * - Highs: 4000-20000 Hz
294
+ *
295
+ * @param duration - Track duration in seconds
296
+ * @param sourceId - The audio source these bands belong to. Defaults to "mixdown".
297
+ * @returns Array of FrequencyBand objects
298
+ */
299
+ export function createStandardBands(duration: number, sourceId: string = "mixdown"): FrequencyBand[] {
300
+ const now = new Date().toISOString();
301
+ const bands: Array<{ label: string; lowHz: number; highHz: number }> = [
302
+ { label: "Sub Bass", lowHz: 20, highHz: 60 },
303
+ { label: "Bass", lowHz: 60, highHz: 250 },
304
+ { label: "Low Mids", lowHz: 250, highHz: 500 },
305
+ { label: "Mids", lowHz: 500, highHz: 2000 },
306
+ { label: "High Mids", lowHz: 2000, highHz: 4000 },
307
+ { label: "Highs", lowHz: 4000, highHz: 20000 },
308
+ ];
309
+
310
+ return bands.map((b, index) => ({
311
+ id: generateBandId(),
312
+ label: b.label,
313
+ sourceId,
314
+ enabled: true,
315
+ timeScope: { kind: "global" } as FrequencyBandTimeScope,
316
+ frequencyShape: [
317
+ {
318
+ startTime: 0,
319
+ endTime: duration,
320
+ lowHzStart: b.lowHz,
321
+ highHzStart: b.highHz,
322
+ lowHzEnd: b.lowHz,
323
+ highHzEnd: b.highHz,
324
+ },
325
+ ],
326
+ sortOrder: index,
327
+ provenance: {
328
+ source: "preset" as const,
329
+ createdAt: now,
330
+ presetName: "Standard 6-Band",
331
+ },
332
+ }));
333
+ }
334
+
335
+ // ----------------------------
336
+ // Query Helpers
337
+ // ----------------------------
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
+
371
+ /**
372
+ * Get all bands active at a given time.
373
+ *
374
+ * A band is active if:
375
+ * - It is enabled
376
+ * - The time falls within its time scope
377
+ *
378
+ * @param structure - Frequency band structure (can be null)
379
+ * @param time - Time in seconds
380
+ * @param sourceId - Optional: filter to a specific audio source
381
+ * @returns Array of active bands
382
+ */
383
+ export function bandsActiveAt(
384
+ structure: FrequencyBandStructure | null,
385
+ time: number,
386
+ sourceId?: string
387
+ ): FrequencyBand[] {
388
+ if (!structure) return [];
389
+
390
+ return structure.bands.filter((band) => {
391
+ if (!band.enabled) return false;
392
+ if (sourceId !== undefined && band.sourceId !== sourceId) return false;
393
+ if (band.timeScope.kind === "global") return true;
394
+ return time >= band.timeScope.startTime && time < band.timeScope.endTime;
395
+ });
396
+ }
397
+
398
+ /**
399
+ * Get frequency bounds for a band at a given time.
400
+ *
401
+ * Uses linear interpolation within segments.
402
+ *
403
+ * @param band - The frequency band to query
404
+ * @param time - Time in seconds
405
+ * @returns Frequency bounds if band is active and has defined bounds, null otherwise
406
+ */
407
+ export function frequencyBoundsAt(
408
+ band: FrequencyBand,
409
+ time: number
410
+ ): FrequencyBoundsAtTime | null {
411
+ // Check if band is active at this time
412
+ if (!band.enabled) return null;
413
+
414
+ if (band.timeScope.kind === "sectioned") {
415
+ if (time < band.timeScope.startTime || time >= band.timeScope.endTime) {
416
+ return null;
417
+ }
418
+ }
419
+
420
+ // Find the segment containing this time
421
+ for (const seg of band.frequencyShape) {
422
+ if (time >= seg.startTime && time < seg.endTime) {
423
+ // Linear interpolation
424
+ const t = (time - seg.startTime) / (seg.endTime - seg.startTime);
425
+ return {
426
+ bandId: band.id,
427
+ lowHz: seg.lowHzStart + (seg.lowHzEnd - seg.lowHzStart) * t,
428
+ highHz: seg.highHzStart + (seg.highHzEnd - seg.highHzStart) * t,
429
+ enabled: band.enabled,
430
+ };
431
+ }
432
+ }
433
+
434
+ // Edge case: time exactly at end of last segment
435
+ const last = band.frequencyShape[band.frequencyShape.length - 1];
436
+ if (last && Math.abs(time - last.endTime) < 0.001) {
437
+ return {
438
+ bandId: band.id,
439
+ lowHz: last.lowHzEnd,
440
+ highHz: last.highHzEnd,
441
+ enabled: band.enabled,
442
+ };
443
+ }
444
+
445
+ return null;
446
+ }
447
+
448
+ /**
449
+ * Get all frequency bounds at a given time.
450
+ *
451
+ * Returns bounds for all active bands that have defined frequency at the given time.
452
+ *
453
+ * @param structure - Frequency band structure (can be null)
454
+ * @param time - Time in seconds
455
+ * @returns Array of frequency bounds, sorted by sortOrder
456
+ */
457
+ export function allFrequencyBoundsAt(
458
+ structure: FrequencyBandStructure | null,
459
+ time: number
460
+ ): FrequencyBoundsAtTime[] {
461
+ if (!structure) return [];
462
+
463
+ const bounds: FrequencyBoundsAtTime[] = [];
464
+
465
+ for (const band of structure.bands) {
466
+ const b = frequencyBoundsAt(band, time);
467
+ if (b) bounds.push(b);
468
+ }
469
+
470
+ // Sort by sortOrder
471
+ bounds.sort((a, b) => {
472
+ const bandA = structure.bands.find((band) => band.id === a.bandId);
473
+ const bandB = structure.bands.find((band) => band.id === b.bandId);
474
+ return (bandA?.sortOrder ?? 0) - (bandB?.sortOrder ?? 0);
475
+ });
476
+
477
+ return bounds;
478
+ }
479
+
480
+ /**
481
+ * Find a band by its ID.
482
+ *
483
+ * @param structure - Frequency band structure (can be null)
484
+ * @param id - Band ID to find
485
+ * @returns The band if found, null otherwise
486
+ */
487
+ export function findBandById(
488
+ structure: FrequencyBandStructure | null,
489
+ id: string
490
+ ): FrequencyBand | null {
491
+ if (!structure) return null;
492
+ return structure.bands.find((b) => b.id === id) ?? null;
493
+ }
494
+
495
+ // ----------------------------
496
+ // Sorting Helpers
497
+ // ----------------------------
498
+
499
+ /**
500
+ * Sort bands by sortOrder.
501
+ * Returns a new array (does not mutate input).
502
+ *
503
+ * @param bands - Bands to sort
504
+ * @returns New array sorted by sortOrder ascending
505
+ */
506
+ export function sortBands(bands: FrequencyBand[]): FrequencyBand[] {
507
+ return [...bands].sort((a, b) => a.sortOrder - b.sortOrder);
508
+ }
509
+
510
+ /**
511
+ * Sort frequency segments by startTime.
512
+ * Returns a new array (does not mutate input).
513
+ *
514
+ * @param segments - Segments to sort
515
+ * @returns New array sorted by startTime ascending
516
+ */
517
+ export function sortFrequencySegments(segments: FrequencySegment[]): FrequencySegment[] {
518
+ return [...segments].sort((a, b) => a.startTime - b.startTime);
519
+ }
520
+
521
+ // ----------------------------
522
+ // Modification Helpers
523
+ // ----------------------------
524
+
525
+ /**
526
+ * Update the modifiedAt timestamp of a structure.
527
+ * Returns a new structure (does not mutate input).
528
+ *
529
+ * @param structure - Structure to update
530
+ * @returns New structure with updated modifiedAt
531
+ */
532
+ export function touchStructure(structure: FrequencyBandStructure): FrequencyBandStructure {
533
+ return {
534
+ ...structure,
535
+ modifiedAt: new Date().toISOString(),
536
+ };
537
+ }
538
+
539
+ /**
540
+ * Add a band to a structure.
541
+ * Returns a new structure (does not mutate input).
542
+ *
543
+ * @param structure - Structure to add to
544
+ * @param band - Band to add
545
+ * @returns New structure with the band added
546
+ */
547
+ export function addBandToStructure(
548
+ structure: FrequencyBandStructure,
549
+ band: FrequencyBand
550
+ ): FrequencyBandStructure {
551
+ return {
552
+ ...structure,
553
+ bands: sortBands([...structure.bands, band]),
554
+ modifiedAt: new Date().toISOString(),
555
+ };
556
+ }
557
+
558
+ /**
559
+ * Remove a band from a structure by ID.
560
+ * Returns a new structure (does not mutate input).
561
+ *
562
+ * @param structure - Structure to remove from
563
+ * @param bandId - ID of band to remove
564
+ * @returns New structure with the band removed
565
+ */
566
+ export function removeBandFromStructure(
567
+ structure: FrequencyBandStructure,
568
+ bandId: string
569
+ ): FrequencyBandStructure {
570
+ return {
571
+ ...structure,
572
+ bands: structure.bands.filter((b) => b.id !== bandId),
573
+ modifiedAt: new Date().toISOString(),
574
+ };
575
+ }
576
+
577
+ /**
578
+ * Update a band in a structure.
579
+ * Returns a new structure (does not mutate input).
580
+ *
581
+ * @param structure - Structure containing the band
582
+ * @param bandId - ID of band to update
583
+ * @param updates - Partial band updates to apply
584
+ * @returns New structure with the band updated
585
+ */
586
+ export function updateBandInStructure(
587
+ structure: FrequencyBandStructure,
588
+ bandId: string,
589
+ updates: Partial<Omit<FrequencyBand, "id">>
590
+ ): FrequencyBandStructure {
591
+ return {
592
+ ...structure,
593
+ bands: sortBands(
594
+ structure.bands.map((b) => (b.id === bandId ? { ...b, ...updates } : b))
595
+ ),
596
+ modifiedAt: new Date().toISOString(),
597
+ };
598
+ }
599
+
600
+ // ----------------------------
601
+ // Keyframe Helpers (F2)
602
+ // ----------------------------
603
+
604
+ /**
605
+ * Extract keyframes from a band's frequency shape.
606
+ *
607
+ * Keyframes are a UI abstraction over the segment model.
608
+ * Each segment has a start keyframe and an end keyframe.
609
+ * Adjacent segments share keyframes at their boundaries.
610
+ *
611
+ * @param band - The frequency band to extract keyframes from
612
+ * @returns Array of keyframes sorted by time
613
+ */
614
+ export function keyframesFromBand(band: FrequencyBand): FrequencyKeyframe[] {
615
+ const keyframes: FrequencyKeyframe[] = [];
616
+ const segments = sortFrequencySegments(band.frequencyShape);
617
+
618
+ for (let i = 0; i < segments.length; i++) {
619
+ const seg = segments[i];
620
+ if (!seg) continue;
621
+
622
+ // Add start keyframe
623
+ keyframes.push({
624
+ time: seg.startTime,
625
+ lowHz: seg.lowHzStart,
626
+ highHz: seg.highHzStart,
627
+ segmentIndex: i,
628
+ edge: "start",
629
+ });
630
+
631
+ // Only add end keyframe if it's not shared with next segment
632
+ const nextSeg = segments[i + 1];
633
+ const isShared = nextSeg && Math.abs(nextSeg.startTime - seg.endTime) < 0.001;
634
+
635
+ if (!isShared) {
636
+ keyframes.push({
637
+ time: seg.endTime,
638
+ lowHz: seg.lowHzEnd,
639
+ highHz: seg.highHzEnd,
640
+ segmentIndex: i,
641
+ edge: "end",
642
+ });
643
+ }
644
+ }
645
+
646
+ return keyframes;
647
+ }
648
+
649
+ /**
650
+ * Convert keyframes back to frequency segments.
651
+ *
652
+ * Consecutive keyframes become segment boundaries.
653
+ * This is the inverse of keyframesFromBand.
654
+ *
655
+ * @param keyframes - Array of keyframes (must be sorted by time)
656
+ * @returns Array of frequency segments
657
+ */
658
+ export function segmentsFromKeyframes(keyframes: FrequencyKeyframe[]): FrequencySegment[] {
659
+ if (keyframes.length < 2) return [];
660
+
661
+ const sorted = [...keyframes].sort((a, b) => a.time - b.time);
662
+ const segments: FrequencySegment[] = [];
663
+
664
+ for (let i = 0; i < sorted.length - 1; i++) {
665
+ const curr = sorted[i];
666
+ const next = sorted[i + 1];
667
+ if (!curr || !next) continue;
668
+
669
+ segments.push({
670
+ startTime: curr.time,
671
+ endTime: next.time,
672
+ lowHzStart: curr.lowHz,
673
+ highHzStart: curr.highHz,
674
+ lowHzEnd: next.lowHz,
675
+ highHzEnd: next.highHz,
676
+ });
677
+ }
678
+
679
+ return segments;
680
+ }
681
+
682
+ /**
683
+ * Split a band's segment at a given time, creating a new keyframe.
684
+ *
685
+ * The frequency values at the split point are interpolated from the
686
+ * existing segment. Returns a new band with updated frequencyShape.
687
+ *
688
+ * @param band - The band to split
689
+ * @param time - Time in seconds to split at
690
+ * @returns New band with the split segment, or original band if time is invalid
691
+ */
692
+ export function splitBandSegmentAt(band: FrequencyBand, time: number): FrequencyBand {
693
+ const segments = sortFrequencySegments(band.frequencyShape);
694
+
695
+ // Find the segment containing this time
696
+ const segmentIndex = segments.findIndex(
697
+ (seg) => time > seg.startTime && time < seg.endTime
698
+ );
699
+
700
+ if (segmentIndex === -1) {
701
+ // Time is not inside any segment
702
+ return band;
703
+ }
704
+
705
+ const seg = segments[segmentIndex];
706
+ if (!seg) return band;
707
+
708
+ // Calculate interpolated values at split point
709
+ const t = (time - seg.startTime) / (seg.endTime - seg.startTime);
710
+ const lowHz = seg.lowHzStart + (seg.lowHzEnd - seg.lowHzStart) * t;
711
+ const highHz = seg.highHzStart + (seg.highHzEnd - seg.highHzStart) * t;
712
+
713
+ // Create two new segments
714
+ const firstHalf: FrequencySegment = {
715
+ startTime: seg.startTime,
716
+ endTime: time,
717
+ lowHzStart: seg.lowHzStart,
718
+ highHzStart: seg.highHzStart,
719
+ lowHzEnd: lowHz,
720
+ highHzEnd: highHz,
721
+ };
722
+
723
+ const secondHalf: FrequencySegment = {
724
+ startTime: time,
725
+ endTime: seg.endTime,
726
+ lowHzStart: lowHz,
727
+ highHzStart: highHz,
728
+ lowHzEnd: seg.lowHzEnd,
729
+ highHzEnd: seg.highHzEnd,
730
+ };
731
+
732
+ // Replace the original segment with the two halves
733
+ const newSegments = [
734
+ ...segments.slice(0, segmentIndex),
735
+ firstHalf,
736
+ secondHalf,
737
+ ...segments.slice(segmentIndex + 1),
738
+ ];
739
+
740
+ return {
741
+ ...band,
742
+ frequencyShape: newSegments,
743
+ };
744
+ }
745
+
746
+ /**
747
+ * Merge adjacent segments where frequencies match at the boundary.
748
+ *
749
+ * This is useful after deleting a keyframe to clean up redundant segments.
750
+ *
751
+ * @param band - The band to merge segments in
752
+ * @param tolerance - Frequency tolerance for considering values equal (default 0.1 Hz)
753
+ * @returns New band with merged segments
754
+ */
755
+ export function mergeAdjacentSegments(
756
+ band: FrequencyBand,
757
+ tolerance: number = 0.1
758
+ ): FrequencyBand {
759
+ const segments = sortFrequencySegments(band.frequencyShape);
760
+
761
+ if (segments.length < 2) return band;
762
+
763
+ const merged: FrequencySegment[] = [];
764
+ let current = segments[0];
765
+
766
+ if (!current) return band;
767
+
768
+ for (let i = 1; i < segments.length; i++) {
769
+ const next = segments[i];
770
+ if (!next) continue;
771
+
772
+ // Check if segments can be merged
773
+ const timeMatches = Math.abs(current.endTime - next.startTime) < 0.001;
774
+ const lowMatches = Math.abs(current.lowHzEnd - next.lowHzStart) < tolerance;
775
+ const highMatches = Math.abs(current.highHzEnd - next.highHzStart) < tolerance;
776
+
777
+ if (timeMatches && lowMatches && highMatches) {
778
+ // Merge: extend current to include next
779
+ current = {
780
+ startTime: current.startTime,
781
+ endTime: next.endTime,
782
+ lowHzStart: current.lowHzStart,
783
+ highHzStart: current.highHzStart,
784
+ lowHzEnd: next.lowHzEnd,
785
+ highHzEnd: next.highHzEnd,
786
+ };
787
+ } else {
788
+ // Can't merge: push current and move on
789
+ merged.push(current);
790
+ current = next;
791
+ }
792
+ }
793
+
794
+ // Don't forget the last segment
795
+ merged.push(current);
796
+
797
+ return {
798
+ ...band,
799
+ frequencyShape: merged,
800
+ };
801
+ }
802
+
803
+ /**
804
+ * Remove a keyframe from a band.
805
+ *
806
+ * This merges the two adjacent segments into one.
807
+ * Cannot remove first or last keyframe (would make band invalid).
808
+ *
809
+ * @param band - The band to modify
810
+ * @param time - Time of the keyframe to remove
811
+ * @returns New band with keyframe removed, or original if removal is invalid
812
+ */
813
+ export function removeKeyframe(band: FrequencyBand, time: number): FrequencyBand {
814
+ const segments = sortFrequencySegments(band.frequencyShape);
815
+
816
+ if (segments.length < 2) return band;
817
+
818
+ // Find the segment that ends at this time and the one that starts at this time
819
+ const endingIndex = segments.findIndex(
820
+ (seg) => Math.abs(seg.endTime - time) < 0.001
821
+ );
822
+ const startingIndex = segments.findIndex(
823
+ (seg) => Math.abs(seg.startTime - time) < 0.001
824
+ );
825
+
826
+ // Can only remove keyframes at segment boundaries (not first or last)
827
+ if (endingIndex === -1 || startingIndex === -1) return band;
828
+ if (endingIndex !== startingIndex - 1) return band;
829
+
830
+ const endingSeg = segments[endingIndex];
831
+ const startingSeg = segments[startingIndex];
832
+
833
+ if (!endingSeg || !startingSeg) return band;
834
+
835
+ // Merge the two segments
836
+ const merged: FrequencySegment = {
837
+ startTime: endingSeg.startTime,
838
+ endTime: startingSeg.endTime,
839
+ lowHzStart: endingSeg.lowHzStart,
840
+ highHzStart: endingSeg.highHzStart,
841
+ lowHzEnd: startingSeg.lowHzEnd,
842
+ highHzEnd: startingSeg.highHzEnd,
843
+ };
844
+
845
+ const newSegments = [
846
+ ...segments.slice(0, endingIndex),
847
+ merged,
848
+ ...segments.slice(startingIndex + 1),
849
+ ];
850
+
851
+ return {
852
+ ...band,
853
+ frequencyShape: newSegments,
854
+ };
855
+ }
856
+
857
+ /**
858
+ * Update a keyframe's frequency values.
859
+ *
860
+ * This updates the corresponding segment boundary.
861
+ *
862
+ * @param band - The band to modify
863
+ * @param time - Time of the keyframe to update
864
+ * @param lowHz - New lower frequency (or undefined to keep current)
865
+ * @param highHz - New upper frequency (or undefined to keep current)
866
+ * @returns New band with updated keyframe
867
+ */
868
+ export function updateKeyframe(
869
+ band: FrequencyBand,
870
+ time: number,
871
+ lowHz?: number,
872
+ highHz?: number
873
+ ): FrequencyBand {
874
+ const segments = band.frequencyShape.map((seg) => {
875
+ const isStart = Math.abs(seg.startTime - time) < 0.001;
876
+ const isEnd = Math.abs(seg.endTime - time) < 0.001;
877
+
878
+ if (!isStart && !isEnd) return seg;
879
+
880
+ const newSeg = { ...seg };
881
+
882
+ if (isStart) {
883
+ if (lowHz !== undefined) newSeg.lowHzStart = lowHz;
884
+ if (highHz !== undefined) newSeg.highHzStart = highHz;
885
+ }
886
+
887
+ if (isEnd) {
888
+ if (lowHz !== undefined) newSeg.lowHzEnd = lowHz;
889
+ if (highHz !== undefined) newSeg.highHzEnd = highHz;
890
+ }
891
+
892
+ return newSeg;
893
+ });
894
+
895
+ return {
896
+ ...band,
897
+ frequencyShape: segments,
898
+ };
899
+ }
900
+
901
+ /**
902
+ * Move a keyframe in time.
903
+ *
904
+ * Updates the endTime of the segment before and startTime of the segment after.
905
+ * Cannot move first or last keyframe.
906
+ *
907
+ * @param band - The band to modify
908
+ * @param oldTime - Current time of the keyframe
909
+ * @param newTime - New time for the keyframe
910
+ * @returns New band with moved keyframe, or original if move is invalid
911
+ */
912
+ export function moveKeyframeTime(
913
+ band: FrequencyBand,
914
+ oldTime: number,
915
+ newTime: number
916
+ ): FrequencyBand {
917
+ const segments = sortFrequencySegments(band.frequencyShape);
918
+
919
+ // Find the segments that share this boundary
920
+ const endingIndex = segments.findIndex(
921
+ (seg) => Math.abs(seg.endTime - oldTime) < 0.001
922
+ );
923
+ const startingIndex = segments.findIndex(
924
+ (seg) => Math.abs(seg.startTime - oldTime) < 0.001
925
+ );
926
+
927
+ // Must be at a segment boundary
928
+ if (endingIndex === -1 || startingIndex === -1) return band;
929
+ if (endingIndex !== startingIndex - 1) return band;
930
+
931
+ const endingSeg = segments[endingIndex];
932
+ const startingSeg = segments[startingIndex];
933
+
934
+ if (!endingSeg || !startingSeg) return band;
935
+
936
+ // Check bounds: newTime must be within the span of both segments
937
+ if (newTime <= endingSeg.startTime || newTime >= startingSeg.endTime) {
938
+ return band;
939
+ }
940
+
941
+ // Update the boundary
942
+ const newSegments = segments.map((seg, i) => {
943
+ if (i === endingIndex) {
944
+ return { ...seg, endTime: newTime };
945
+ }
946
+ if (i === startingIndex) {
947
+ return { ...seg, startTime: newTime };
948
+ }
949
+ return seg;
950
+ });
951
+
952
+ return {
953
+ ...band,
954
+ frequencyShape: newSegments,
955
+ };
956
+ }