@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,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
|
+
}
|