@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
package/dist/index.js
CHANGED
|
@@ -1,36 +1,3499 @@
|
|
|
1
|
-
import { peakPick } from './chunk-
|
|
2
|
-
export { delta, deltaDelta, hpss, melSpectrogram, mfcc, onsetEnvelopeFromMel, onsetEnvelopeFromMelGpu, onsetEnvelopeFromSpectrogram, peakPick, runMir, spectralCentroid, spectralFlux, spectrogram } from './chunk-
|
|
1
|
+
import { peakPick, hzToCqtBin, cqtBinToHz, withCqtDefaults, cqtSpectrogram, harmonicEnergy, bassPitchMotion, tonalStability, getNumBins } from './chunk-HF3QHCRK.js';
|
|
2
|
+
export { CQT_DEFAULTS, amplitudeEnvelope, applyActivityGating, bassPitchMotion, beatSalienceFromMel, buildActivityMask, computeActivityFromAudio, computeActivityFromMel, computeActivityFromSpectrogram, computeAllCqtSignals, computeCqt, computeCqtSignal, computeFrameEnergyFromMel, computeFrameEnergyFromSpectrogram, computeSilenceGating, cqtBinToHz, cqtSpectrogram, delta, deltaDelta, detectBeatCandidates, estimateNoiseFloor, featureIndexToHz, generateTempoHypotheses, getCqtBinFrequencies, getNumBins, getNumOctaves, harmonicEnergy, hpss, hzToCqtBin, hzToFeatureIndex, hzToMel, interpolateActivity, melSpectrogram, melToHz, mfcc, onsetEnvelopeFromMel, onsetEnvelopeFromMelGpu, onsetEnvelopeFromSpectrogram, peakPick, pitchConfidence, pitchF0, runMir, spectralCentroid, spectralFlux, spectrogram, tonalStability, withBinGateDefaults, withCqtDefaults, withSilenceGateDefaults } from './chunk-HF3QHCRK.js';
|
|
3
|
+
|
|
4
|
+
// src/gpu/bufferPool.ts
|
|
5
|
+
var DEFAULT_OPTIONS = {
|
|
6
|
+
maxBuffersPerUsage: 32,
|
|
7
|
+
bufferTTLMs: 6e4,
|
|
8
|
+
enableAutoCleanup: true,
|
|
9
|
+
cleanupIntervalMs: 3e4
|
|
10
|
+
};
|
|
11
|
+
function makePoolKey(size, usage) {
|
|
12
|
+
return `${size}-${usage}`;
|
|
13
|
+
}
|
|
14
|
+
var BufferPool = class {
|
|
15
|
+
device;
|
|
16
|
+
options;
|
|
17
|
+
// Available buffers indexed by size+usage key
|
|
18
|
+
availableBuffers = /* @__PURE__ */ new Map();
|
|
19
|
+
// Currently in-use buffers (for tracking and stats)
|
|
20
|
+
activeBuffers = /* @__PURE__ */ new Set();
|
|
21
|
+
// Cleanup interval handle
|
|
22
|
+
cleanupInterval = null;
|
|
23
|
+
constructor(device, options = {}) {
|
|
24
|
+
this.device = device;
|
|
25
|
+
this.options = { ...DEFAULT_OPTIONS, ...options };
|
|
26
|
+
if (this.options.enableAutoCleanup) {
|
|
27
|
+
this.startAutoCleanup();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Acquire a buffer from the pool or create a new one if none available.
|
|
32
|
+
*
|
|
33
|
+
* @param size - Byte size of the buffer
|
|
34
|
+
* @param usage - Buffer usage flags (e.g. GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST)
|
|
35
|
+
* @returns A GPUBuffer ready for use
|
|
36
|
+
*/
|
|
37
|
+
acquire(size, usage) {
|
|
38
|
+
const key = makePoolKey(size, usage);
|
|
39
|
+
const pool = this.availableBuffers.get(key);
|
|
40
|
+
if (pool && pool.length > 0) {
|
|
41
|
+
const entry = pool.pop();
|
|
42
|
+
this.activeBuffers.add(entry.buffer);
|
|
43
|
+
return entry.buffer;
|
|
44
|
+
}
|
|
45
|
+
const buffer = this.device.createBuffer({ size, usage });
|
|
46
|
+
this.activeBuffers.add(buffer);
|
|
47
|
+
return buffer;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Release a buffer back to the pool for reuse.
|
|
51
|
+
*
|
|
52
|
+
* @param buffer - The buffer to release
|
|
53
|
+
* @param size - Original size of the buffer
|
|
54
|
+
* @param usage - Original usage flags of the buffer
|
|
55
|
+
*/
|
|
56
|
+
release(buffer, size, usage) {
|
|
57
|
+
if (!this.activeBuffers.has(buffer)) {
|
|
58
|
+
console.warn("@octoseq/mir: Attempting to release buffer not tracked by pool");
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
this.activeBuffers.delete(buffer);
|
|
62
|
+
const key = makePoolKey(size, usage);
|
|
63
|
+
let pool = this.availableBuffers.get(key);
|
|
64
|
+
if (!pool) {
|
|
65
|
+
pool = [];
|
|
66
|
+
this.availableBuffers.set(key, pool);
|
|
67
|
+
}
|
|
68
|
+
if (pool.length >= this.options.maxBuffersPerUsage) {
|
|
69
|
+
buffer.destroy();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
pool.push({
|
|
73
|
+
buffer,
|
|
74
|
+
size,
|
|
75
|
+
usage,
|
|
76
|
+
lastUsed: performance.now()
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Clean up old unused buffers that exceed the TTL.
|
|
81
|
+
*/
|
|
82
|
+
cleanup() {
|
|
83
|
+
const now = performance.now();
|
|
84
|
+
const ttl = this.options.bufferTTLMs;
|
|
85
|
+
for (const [key, pool] of this.availableBuffers.entries()) {
|
|
86
|
+
const remaining = [];
|
|
87
|
+
for (const entry of pool) {
|
|
88
|
+
if (now - entry.lastUsed > ttl) {
|
|
89
|
+
entry.buffer.destroy();
|
|
90
|
+
} else {
|
|
91
|
+
remaining.push(entry);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (remaining.length === 0) {
|
|
95
|
+
this.availableBuffers.delete(key);
|
|
96
|
+
} else {
|
|
97
|
+
this.availableBuffers.set(key, remaining);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Destroy all pooled buffers and clear the pool.
|
|
103
|
+
*/
|
|
104
|
+
clear() {
|
|
105
|
+
for (const pool of this.availableBuffers.values()) {
|
|
106
|
+
for (const entry of pool) {
|
|
107
|
+
entry.buffer.destroy();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
this.availableBuffers.clear();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get statistics about the buffer pool.
|
|
114
|
+
*/
|
|
115
|
+
getStats() {
|
|
116
|
+
let totalBuffers = this.activeBuffers.size;
|
|
117
|
+
let totalMemoryBytes = 0;
|
|
118
|
+
const poolsByUsage = /* @__PURE__ */ new Map();
|
|
119
|
+
for (const pool of this.availableBuffers.values()) {
|
|
120
|
+
totalBuffers += pool.length;
|
|
121
|
+
for (const entry of pool) {
|
|
122
|
+
totalMemoryBytes += entry.size;
|
|
123
|
+
const count = poolsByUsage.get(entry.usage) || 0;
|
|
124
|
+
poolsByUsage.set(entry.usage, count + 1);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
totalBuffers,
|
|
129
|
+
activeBuffers: this.activeBuffers.size,
|
|
130
|
+
availableBuffers: totalBuffers - this.activeBuffers.size,
|
|
131
|
+
poolsByUsage,
|
|
132
|
+
totalMemoryBytes
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Start automatic cleanup of old buffers.
|
|
137
|
+
*/
|
|
138
|
+
startAutoCleanup() {
|
|
139
|
+
if (this.cleanupInterval !== null) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
this.cleanupInterval = setInterval(() => {
|
|
143
|
+
this.cleanup();
|
|
144
|
+
}, this.options.cleanupIntervalMs);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Stop automatic cleanup.
|
|
148
|
+
*/
|
|
149
|
+
stopAutoCleanup() {
|
|
150
|
+
if (this.cleanupInterval !== null) {
|
|
151
|
+
clearInterval(this.cleanupInterval);
|
|
152
|
+
this.cleanupInterval = null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Dispose of the buffer pool and destroy all buffers.
|
|
157
|
+
*/
|
|
158
|
+
dispose() {
|
|
159
|
+
this.stopAutoCleanup();
|
|
160
|
+
this.clear();
|
|
161
|
+
}
|
|
162
|
+
};
|
|
3
163
|
|
|
4
164
|
// src/gpu/context.ts
|
|
5
165
|
var MirGPU = class _MirGPU {
|
|
6
166
|
device;
|
|
7
167
|
queue;
|
|
8
|
-
|
|
168
|
+
bufferPool;
|
|
169
|
+
constructor(device, bufferPoolOptions) {
|
|
9
170
|
this.device = device;
|
|
10
171
|
this.queue = device.queue;
|
|
172
|
+
this.bufferPool = new BufferPool(device, bufferPoolOptions);
|
|
173
|
+
}
|
|
174
|
+
static async create(bufferPoolOptions) {
|
|
175
|
+
if (typeof navigator === "undefined") {
|
|
176
|
+
throw new Error(
|
|
177
|
+
"@octoseq/mir: WebGPU is only available in the browser (navigator is undefined)."
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
const nav = navigator;
|
|
181
|
+
if (!nav.gpu) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"@octoseq/mir: WebGPU is unavailable (navigator.gpu is missing). Use CPU mode or a WebGPU-capable browser."
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const adapter = await nav.gpu.requestAdapter();
|
|
187
|
+
if (!adapter) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
"@octoseq/mir: Failed to acquire a WebGPU adapter. WebGPU may be disabled or unsupported."
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
const device = await adapter.requestDevice();
|
|
193
|
+
return new _MirGPU(device, bufferPoolOptions);
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* Get statistics about buffer pool usage.
|
|
197
|
+
*/
|
|
198
|
+
getBufferPoolStats() {
|
|
199
|
+
return this.bufferPool.getStats();
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Manually trigger buffer pool cleanup (normally runs automatically).
|
|
203
|
+
*/
|
|
204
|
+
cleanupBufferPool() {
|
|
205
|
+
this.bufferPool.cleanup();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Dispose of the GPU context and clean up all resources.
|
|
209
|
+
*/
|
|
210
|
+
dispose() {
|
|
211
|
+
this.bufferPool.dispose();
|
|
212
|
+
}
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
// src/dsp/phaseAlignment.ts
|
|
216
|
+
var DEFAULT_CONFIG = {
|
|
217
|
+
phaseResolution: 16,
|
|
218
|
+
matchTolerance: 0.05,
|
|
219
|
+
// 50ms
|
|
220
|
+
topK: 3,
|
|
221
|
+
offsetPenaltyWeight: 0.2
|
|
222
|
+
};
|
|
223
|
+
function computePhaseHypotheses(bpm, candidates, audioDuration, config) {
|
|
224
|
+
const cfg = { ...DEFAULT_CONFIG, ...config };
|
|
225
|
+
const period = 60 / bpm;
|
|
226
|
+
if (candidates.length === 0 || audioDuration <= 0 || bpm <= 0) {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
const phases = [];
|
|
230
|
+
for (let i = 0; i < cfg.phaseResolution; i++) {
|
|
231
|
+
const phaseOffset = i / cfg.phaseResolution * period;
|
|
232
|
+
const result = scorePhase(phaseOffset, period, candidates, audioDuration, cfg.matchTolerance);
|
|
233
|
+
phases.push({
|
|
234
|
+
index: i,
|
|
235
|
+
phaseOffset,
|
|
236
|
+
score: result.score,
|
|
237
|
+
matchCount: result.matchCount,
|
|
238
|
+
avgOffsetError: result.avgOffsetError
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
const maxScore = Math.max(...phases.map((p) => p.score), 1e-9);
|
|
242
|
+
for (const phase of phases) {
|
|
243
|
+
const penalty = phase.avgOffsetError / cfg.matchTolerance * cfg.offsetPenaltyWeight;
|
|
244
|
+
phase.score = phase.score / maxScore * (1 - Math.min(1, penalty));
|
|
245
|
+
}
|
|
246
|
+
phases.sort((a, b) => b.score - a.score);
|
|
247
|
+
return phases.slice(0, cfg.topK);
|
|
248
|
+
}
|
|
249
|
+
function scorePhase(phaseOffset, period, candidates, audioDuration, tolerance) {
|
|
250
|
+
let score = 0;
|
|
251
|
+
let matchCount = 0;
|
|
252
|
+
let totalOffsetError = 0;
|
|
253
|
+
for (const candidate of candidates) {
|
|
254
|
+
const beatsFromStart = (candidate.time - phaseOffset) / period;
|
|
255
|
+
const nearestBeatIndex = Math.round(beatsFromStart);
|
|
256
|
+
const nearestBeatTime = phaseOffset + nearestBeatIndex * period;
|
|
257
|
+
if (nearestBeatTime < 0 || nearestBeatTime > audioDuration) continue;
|
|
258
|
+
const offset = Math.abs(candidate.time - nearestBeatTime);
|
|
259
|
+
if (offset <= tolerance) {
|
|
260
|
+
const weight = Math.exp(-offset * offset / (2 * tolerance * tolerance));
|
|
261
|
+
score += candidate.strength * weight;
|
|
262
|
+
matchCount++;
|
|
263
|
+
totalOffsetError += offset;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const avgOffsetError = matchCount > 0 ? totalOffsetError / matchCount : 0;
|
|
267
|
+
return { score, matchCount, avgOffsetError };
|
|
268
|
+
}
|
|
269
|
+
function generateBeatTimes(bpm, phaseOffset, userNudge, audioDuration) {
|
|
270
|
+
if (bpm <= 0 || audioDuration <= 0) {
|
|
271
|
+
return [];
|
|
272
|
+
}
|
|
273
|
+
const period = 60 / bpm;
|
|
274
|
+
const effectivePhase = phaseOffset + userNudge;
|
|
275
|
+
const beats = [];
|
|
276
|
+
const firstBeatIndex = Math.ceil(-effectivePhase / period);
|
|
277
|
+
let time = effectivePhase + firstBeatIndex * period;
|
|
278
|
+
while (time <= audioDuration) {
|
|
279
|
+
if (time >= 0) {
|
|
280
|
+
beats.push(time);
|
|
281
|
+
}
|
|
282
|
+
time += period;
|
|
283
|
+
}
|
|
284
|
+
return beats;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// src/dsp/peakPicking.ts
|
|
288
|
+
var DEFAULT_PEAK_PICKING_PARAMS = {
|
|
289
|
+
threshold: 0.3,
|
|
290
|
+
minDistance: 0.1,
|
|
291
|
+
preMax: 2,
|
|
292
|
+
postMax: 2
|
|
293
|
+
};
|
|
294
|
+
function pickPeaks(times, values, params = {}) {
|
|
295
|
+
const {
|
|
296
|
+
threshold,
|
|
297
|
+
minDistance,
|
|
298
|
+
preMax,
|
|
299
|
+
postMax
|
|
300
|
+
} = { ...DEFAULT_PEAK_PICKING_PARAMS, ...params };
|
|
301
|
+
if (times.length === 0 || values.length === 0) {
|
|
302
|
+
return {
|
|
303
|
+
times: new Float32Array(0),
|
|
304
|
+
strengths: new Float32Array(0)
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
let minVal = values[0];
|
|
308
|
+
let maxVal = values[0];
|
|
309
|
+
for (let i = 1; i < values.length; i++) {
|
|
310
|
+
const v = values[i];
|
|
311
|
+
if (v < minVal) minVal = v;
|
|
312
|
+
if (v > maxVal) maxVal = v;
|
|
313
|
+
}
|
|
314
|
+
const range = maxVal - minVal;
|
|
315
|
+
if (range <= 0) {
|
|
316
|
+
return {
|
|
317
|
+
times: new Float32Array(0),
|
|
318
|
+
strengths: new Float32Array(0)
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
const normalize = (v) => (v - minVal) / range;
|
|
322
|
+
const peakIndices = [];
|
|
323
|
+
const peakStrengths = [];
|
|
324
|
+
for (let i = preMax; i < values.length - postMax; i++) {
|
|
325
|
+
const currentVal = values[i];
|
|
326
|
+
const normalizedVal = normalize(currentVal);
|
|
327
|
+
if (normalizedVal < threshold) continue;
|
|
328
|
+
let isMax = true;
|
|
329
|
+
for (let j = 1; j <= preMax; j++) {
|
|
330
|
+
if (values[i - j] >= currentVal) {
|
|
331
|
+
isMax = false;
|
|
332
|
+
break;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (isMax) {
|
|
336
|
+
for (let j = 1; j <= postMax; j++) {
|
|
337
|
+
if (values[i + j] > currentVal) {
|
|
338
|
+
isMax = false;
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (isMax) {
|
|
344
|
+
peakIndices.push(i);
|
|
345
|
+
peakStrengths.push(normalizedVal);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const filteredIndices = [];
|
|
349
|
+
const filteredStrengths = [];
|
|
350
|
+
for (let i = 0; i < peakIndices.length; i++) {
|
|
351
|
+
const idx = peakIndices[i];
|
|
352
|
+
const time = times[idx];
|
|
353
|
+
const strength = peakStrengths[i];
|
|
354
|
+
let shouldKeep = true;
|
|
355
|
+
for (let j = 0; j < filteredIndices.length; j++) {
|
|
356
|
+
const prevIdx = filteredIndices[j];
|
|
357
|
+
const prevTime = times[prevIdx];
|
|
358
|
+
const prevStrength = filteredStrengths[j];
|
|
359
|
+
if (Math.abs(time - prevTime) < minDistance) {
|
|
360
|
+
if (strength > prevStrength) {
|
|
361
|
+
filteredIndices[j] = idx;
|
|
362
|
+
filteredStrengths[j] = strength;
|
|
363
|
+
}
|
|
364
|
+
shouldKeep = false;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
if (shouldKeep) {
|
|
369
|
+
filteredIndices.push(idx);
|
|
370
|
+
filteredStrengths.push(strength);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
const resultTimes = new Float32Array(filteredIndices.length);
|
|
374
|
+
const resultStrengths = new Float32Array(filteredStrengths.length);
|
|
375
|
+
for (let i = 0; i < filteredIndices.length; i++) {
|
|
376
|
+
resultTimes[i] = times[filteredIndices[i]];
|
|
377
|
+
resultStrengths[i] = filteredStrengths[i];
|
|
378
|
+
}
|
|
379
|
+
return {
|
|
380
|
+
times: resultTimes,
|
|
381
|
+
strengths: resultStrengths
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
function computeAdaptiveThreshold(values, windowSize = 20, thresholdMultiplier = 1.5) {
|
|
385
|
+
if (values.length === 0) {
|
|
386
|
+
return new Float32Array(0);
|
|
387
|
+
}
|
|
388
|
+
let minVal = values[0];
|
|
389
|
+
let maxVal = values[0];
|
|
390
|
+
for (let i = 1; i < values.length; i++) {
|
|
391
|
+
const v = values[i];
|
|
392
|
+
if (v < minVal) minVal = v;
|
|
393
|
+
if (v > maxVal) maxVal = v;
|
|
394
|
+
}
|
|
395
|
+
const range = maxVal - minVal;
|
|
396
|
+
if (range <= 0) {
|
|
397
|
+
return new Float32Array(values.length).fill(0.5);
|
|
398
|
+
}
|
|
399
|
+
const halfWindow = Math.floor(windowSize / 2);
|
|
400
|
+
const thresholdCurve = new Float32Array(values.length);
|
|
401
|
+
for (let i = 0; i < values.length; i++) {
|
|
402
|
+
const start = Math.max(0, i - halfWindow);
|
|
403
|
+
const end = Math.min(values.length, i + halfWindow + 1);
|
|
404
|
+
const windowLen = end - start;
|
|
405
|
+
let sum = 0;
|
|
406
|
+
for (let j = start; j < end; j++) {
|
|
407
|
+
sum += values[j];
|
|
408
|
+
}
|
|
409
|
+
const mean = sum / windowLen;
|
|
410
|
+
let sumSq = 0;
|
|
411
|
+
for (let j = start; j < end; j++) {
|
|
412
|
+
const diff = values[j] - mean;
|
|
413
|
+
sumSq += diff * diff;
|
|
414
|
+
}
|
|
415
|
+
const std = Math.sqrt(sumSq / windowLen);
|
|
416
|
+
const adaptiveThresh = mean + thresholdMultiplier * std;
|
|
417
|
+
thresholdCurve[i] = Math.max(0, Math.min(1, (adaptiveThresh - minVal) / range));
|
|
418
|
+
}
|
|
419
|
+
return thresholdCurve;
|
|
420
|
+
}
|
|
421
|
+
function pickPeaksAdaptive(times, values, windowSize = 20, params = {}, includeThresholdCurve = false) {
|
|
422
|
+
const {
|
|
423
|
+
threshold,
|
|
424
|
+
minDistance,
|
|
425
|
+
preMax,
|
|
426
|
+
postMax
|
|
427
|
+
} = { ...DEFAULT_PEAK_PICKING_PARAMS, threshold: 1.5, ...params };
|
|
428
|
+
if (times.length === 0 || values.length === 0) {
|
|
429
|
+
return {
|
|
430
|
+
times: new Float32Array(0),
|
|
431
|
+
strengths: new Float32Array(0)
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const adaptiveThreshold = new Float32Array(values.length);
|
|
435
|
+
const halfWindow = Math.floor(windowSize / 2);
|
|
436
|
+
for (let i = 0; i < values.length; i++) {
|
|
437
|
+
const start = Math.max(0, i - halfWindow);
|
|
438
|
+
const end = Math.min(values.length, i + halfWindow + 1);
|
|
439
|
+
const windowLen = end - start;
|
|
440
|
+
let sum = 0;
|
|
441
|
+
for (let j = start; j < end; j++) {
|
|
442
|
+
sum += values[j];
|
|
443
|
+
}
|
|
444
|
+
const mean = sum / windowLen;
|
|
445
|
+
let sumSq = 0;
|
|
446
|
+
for (let j = start; j < end; j++) {
|
|
447
|
+
const diff = values[j] - mean;
|
|
448
|
+
sumSq += diff * diff;
|
|
449
|
+
}
|
|
450
|
+
const std = Math.sqrt(sumSq / windowLen);
|
|
451
|
+
adaptiveThreshold[i] = mean + threshold * std;
|
|
452
|
+
}
|
|
453
|
+
let minVal = values[0];
|
|
454
|
+
let maxVal = values[0];
|
|
455
|
+
for (let i = 1; i < values.length; i++) {
|
|
456
|
+
const v = values[i];
|
|
457
|
+
if (v < minVal) minVal = v;
|
|
458
|
+
if (v > maxVal) maxVal = v;
|
|
459
|
+
}
|
|
460
|
+
const range = maxVal - minVal;
|
|
461
|
+
const normalize = range > 0 ? (v) => (v - minVal) / range : () => 0.5;
|
|
462
|
+
const peakIndices = [];
|
|
463
|
+
const peakStrengths = [];
|
|
464
|
+
for (let i = preMax; i < values.length - postMax; i++) {
|
|
465
|
+
const currentVal = values[i];
|
|
466
|
+
if (currentVal < adaptiveThreshold[i]) continue;
|
|
467
|
+
let isMax = true;
|
|
468
|
+
for (let j = 1; j <= preMax; j++) {
|
|
469
|
+
if (values[i - j] >= currentVal) {
|
|
470
|
+
isMax = false;
|
|
471
|
+
break;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (isMax) {
|
|
475
|
+
for (let j = 1; j <= postMax; j++) {
|
|
476
|
+
if (values[i + j] > currentVal) {
|
|
477
|
+
isMax = false;
|
|
478
|
+
break;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
if (isMax) {
|
|
483
|
+
peakIndices.push(i);
|
|
484
|
+
peakStrengths.push(normalize(currentVal));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const filteredIndices = [];
|
|
488
|
+
const filteredStrengths = [];
|
|
489
|
+
for (let i = 0; i < peakIndices.length; i++) {
|
|
490
|
+
const idx = peakIndices[i];
|
|
491
|
+
const time = times[idx];
|
|
492
|
+
const strength = peakStrengths[i];
|
|
493
|
+
let shouldKeep = true;
|
|
494
|
+
for (let j = 0; j < filteredIndices.length; j++) {
|
|
495
|
+
const prevIdx = filteredIndices[j];
|
|
496
|
+
const prevTime = times[prevIdx];
|
|
497
|
+
const prevStrength = filteredStrengths[j];
|
|
498
|
+
if (Math.abs(time - prevTime) < minDistance) {
|
|
499
|
+
if (strength > prevStrength) {
|
|
500
|
+
filteredIndices[j] = idx;
|
|
501
|
+
filteredStrengths[j] = strength;
|
|
502
|
+
}
|
|
503
|
+
shouldKeep = false;
|
|
504
|
+
break;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (shouldKeep) {
|
|
508
|
+
filteredIndices.push(idx);
|
|
509
|
+
filteredStrengths.push(strength);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const resultTimes = new Float32Array(filteredIndices.length);
|
|
513
|
+
const resultStrengths = new Float32Array(filteredStrengths.length);
|
|
514
|
+
for (let i = 0; i < filteredIndices.length; i++) {
|
|
515
|
+
resultTimes[i] = times[filteredIndices[i]];
|
|
516
|
+
resultStrengths[i] = filteredStrengths[i];
|
|
517
|
+
}
|
|
518
|
+
const baseResult = {
|
|
519
|
+
times: resultTimes,
|
|
520
|
+
strengths: resultStrengths
|
|
521
|
+
};
|
|
522
|
+
if (!includeThresholdCurve) {
|
|
523
|
+
return baseResult;
|
|
524
|
+
}
|
|
525
|
+
const normalizedThreshold = new Float32Array(values.length);
|
|
526
|
+
for (let i = 0; i < values.length; i++) {
|
|
527
|
+
normalizedThreshold[i] = range > 0 ? Math.max(0, Math.min(1, (adaptiveThreshold[i] - minVal) / range)) : 0.5;
|
|
528
|
+
}
|
|
529
|
+
return {
|
|
530
|
+
...baseResult,
|
|
531
|
+
thresholdCurve: normalizedThreshold,
|
|
532
|
+
thresholdTimes: times
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
function applyHysteresisGate(times, values, peakTimes, peakStrengths, params) {
|
|
536
|
+
const { onThreshold, offThreshold, minDistance } = params;
|
|
537
|
+
if (peakTimes.length === 0) {
|
|
538
|
+
return { times: new Float32Array(0), strengths: new Float32Array(0) };
|
|
539
|
+
}
|
|
540
|
+
let minVal = values[0];
|
|
541
|
+
let maxVal = values[0];
|
|
542
|
+
for (let i = 1; i < values.length; i++) {
|
|
543
|
+
const v = values[i];
|
|
544
|
+
if (v < minVal) minVal = v;
|
|
545
|
+
if (v > maxVal) maxVal = v;
|
|
546
|
+
}
|
|
547
|
+
const range = maxVal - minVal;
|
|
548
|
+
const normalize = range > 0 ? (v) => (v - minVal) / range : () => 0.5;
|
|
549
|
+
const getIndexForTime = (t) => {
|
|
550
|
+
let lo = 0;
|
|
551
|
+
let hi = times.length - 1;
|
|
552
|
+
while (lo < hi) {
|
|
553
|
+
const mid = Math.floor((lo + hi) / 2);
|
|
554
|
+
if (times[mid] < t) {
|
|
555
|
+
lo = mid + 1;
|
|
556
|
+
} else {
|
|
557
|
+
hi = mid;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
return lo;
|
|
561
|
+
};
|
|
562
|
+
const filteredTimes = [];
|
|
563
|
+
const filteredStrengths = [];
|
|
564
|
+
let lastPeakTime = -Infinity;
|
|
565
|
+
let gateOpen = true;
|
|
566
|
+
for (let i = 0; i < peakTimes.length; i++) {
|
|
567
|
+
const peakTime = peakTimes[i];
|
|
568
|
+
const peakStrength = peakStrengths[i];
|
|
569
|
+
if (peakTime - lastPeakTime < minDistance) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (!gateOpen) {
|
|
573
|
+
const startIdx = getIndexForTime(lastPeakTime);
|
|
574
|
+
const endIdx = getIndexForTime(peakTime);
|
|
575
|
+
for (let j = startIdx; j < endIdx && j < values.length; j++) {
|
|
576
|
+
if (normalize(values[j]) < offThreshold) {
|
|
577
|
+
gateOpen = true;
|
|
578
|
+
break;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (gateOpen && peakStrength >= onThreshold) {
|
|
583
|
+
filteredTimes.push(peakTime);
|
|
584
|
+
filteredStrengths.push(peakStrength);
|
|
585
|
+
lastPeakTime = peakTime;
|
|
586
|
+
gateOpen = false;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
return {
|
|
590
|
+
times: new Float32Array(filteredTimes),
|
|
591
|
+
strengths: new Float32Array(filteredStrengths)
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// src/dsp/musicalTime.ts
|
|
596
|
+
function findSegmentAtTime(time, segments) {
|
|
597
|
+
for (const segment of segments) {
|
|
598
|
+
if (time >= segment.startTime && time < segment.endTime) {
|
|
599
|
+
return segment;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
return null;
|
|
603
|
+
}
|
|
604
|
+
function computeBeatPosition(time, segments) {
|
|
605
|
+
const segment = findSegmentAtTime(time, segments);
|
|
606
|
+
if (!segment) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
const period = 60 / segment.bpm;
|
|
610
|
+
const beatsFromStart = (time - segment.phaseOffset) / period;
|
|
611
|
+
const beatIndex = Math.floor(beatsFromStart);
|
|
612
|
+
const beatPhase = beatsFromStart - beatIndex;
|
|
613
|
+
return {
|
|
614
|
+
segmentId: segment.id,
|
|
615
|
+
beatIndex,
|
|
616
|
+
beatPhase,
|
|
617
|
+
beatPosition: beatsFromStart,
|
|
618
|
+
bpm: segment.bpm
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
function computeBeatPositionFromStructure(time, structure) {
|
|
622
|
+
if (!structure || structure.segments.length === 0) {
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
625
|
+
return computeBeatPosition(time, structure.segments);
|
|
626
|
+
}
|
|
627
|
+
function generateSegmentId() {
|
|
628
|
+
return `seg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
629
|
+
}
|
|
630
|
+
function createSegmentFromGrid(grid, startTime, endTime) {
|
|
631
|
+
const effectivePhaseOffset = grid.phaseOffset + grid.userNudge;
|
|
632
|
+
return {
|
|
633
|
+
id: generateSegmentId(),
|
|
634
|
+
bpm: grid.bpm,
|
|
635
|
+
phaseOffset: effectivePhaseOffset,
|
|
636
|
+
startTime,
|
|
637
|
+
endTime,
|
|
638
|
+
confidence: grid.confidence,
|
|
639
|
+
provenance: {
|
|
640
|
+
source: "promoted_from_hypothesis",
|
|
641
|
+
sourceHypothesisId: grid.sourceHypothesisId,
|
|
642
|
+
promotedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
643
|
+
userNudge: grid.userNudge
|
|
644
|
+
}
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
function createMusicalTimeStructure() {
|
|
648
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
649
|
+
return {
|
|
650
|
+
version: 1,
|
|
651
|
+
segments: [],
|
|
652
|
+
createdAt: now,
|
|
653
|
+
modifiedAt: now
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function validateSegments(segments) {
|
|
657
|
+
const errors = [];
|
|
658
|
+
for (let i = 0; i < segments.length; i++) {
|
|
659
|
+
const seg = segments[i];
|
|
660
|
+
if (!seg) continue;
|
|
661
|
+
if (seg.startTime >= seg.endTime) {
|
|
662
|
+
errors.push(`Segment ${seg.id}: startTime (${seg.startTime}) >= endTime (${seg.endTime})`);
|
|
663
|
+
}
|
|
664
|
+
if (seg.bpm <= 0) {
|
|
665
|
+
errors.push(`Segment ${seg.id}: bpm (${seg.bpm}) must be positive`);
|
|
666
|
+
}
|
|
667
|
+
if (i < segments.length - 1) {
|
|
668
|
+
const next = segments[i + 1];
|
|
669
|
+
if (!next) continue;
|
|
670
|
+
if (seg.startTime >= next.startTime) {
|
|
671
|
+
errors.push(`Segment ${seg.id} not ordered before ${next.id}`);
|
|
672
|
+
}
|
|
673
|
+
if (seg.endTime > next.startTime) {
|
|
674
|
+
errors.push(`Segment ${seg.id} overlaps with ${next.id}`);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return errors;
|
|
679
|
+
}
|
|
680
|
+
function sortSegments(segments) {
|
|
681
|
+
return [...segments].sort((a, b) => a.startTime - b.startTime);
|
|
682
|
+
}
|
|
683
|
+
function splitSegment(segment, splitTime) {
|
|
684
|
+
if (splitTime <= segment.startTime || splitTime >= segment.endTime) {
|
|
685
|
+
throw new Error(
|
|
686
|
+
`Split time ${splitTime} must be within segment bounds [${segment.startTime}, ${segment.endTime})`
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
const beforeSegment = {
|
|
690
|
+
...segment,
|
|
691
|
+
id: generateSegmentId(),
|
|
692
|
+
endTime: splitTime
|
|
693
|
+
};
|
|
694
|
+
const afterSegment = {
|
|
695
|
+
...segment,
|
|
696
|
+
id: generateSegmentId(),
|
|
697
|
+
startTime: splitTime
|
|
698
|
+
// phaseOffset stays the same - beat grid continues seamlessly
|
|
699
|
+
};
|
|
700
|
+
return [beforeSegment, afterSegment];
|
|
701
|
+
}
|
|
702
|
+
function generateSegmentBeatTimes(segment) {
|
|
703
|
+
const period = 60 / segment.bpm;
|
|
704
|
+
const times = [];
|
|
705
|
+
const beatsBeforeStart = Math.ceil((segment.startTime - segment.phaseOffset) / period);
|
|
706
|
+
let time = segment.phaseOffset + beatsBeforeStart * period;
|
|
707
|
+
while (time < segment.endTime) {
|
|
708
|
+
if (time >= segment.startTime) {
|
|
709
|
+
times.push(time);
|
|
710
|
+
}
|
|
711
|
+
time += period;
|
|
712
|
+
}
|
|
713
|
+
return times;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/dsp/frequencyBand.ts
|
|
717
|
+
function generateBandId() {
|
|
718
|
+
return `band-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
719
|
+
}
|
|
720
|
+
function validateFrequencySegments(segments) {
|
|
721
|
+
const errors = [];
|
|
722
|
+
for (let i = 0; i < segments.length; i++) {
|
|
723
|
+
const seg = segments[i];
|
|
724
|
+
if (!seg) continue;
|
|
725
|
+
if (seg.lowHzStart < 0 || seg.highHzStart < 0) {
|
|
726
|
+
errors.push(`Segment ${i}: start frequencies must be non-negative`);
|
|
727
|
+
}
|
|
728
|
+
if (seg.lowHzStart >= seg.highHzStart) {
|
|
729
|
+
errors.push(
|
|
730
|
+
`Segment ${i}: lowHzStart (${seg.lowHzStart}) must be < highHzStart (${seg.highHzStart})`
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
if (seg.lowHzEnd < 0 || seg.highHzEnd < 0) {
|
|
734
|
+
errors.push(`Segment ${i}: end frequencies must be non-negative`);
|
|
735
|
+
}
|
|
736
|
+
if (seg.lowHzEnd >= seg.highHzEnd) {
|
|
737
|
+
errors.push(
|
|
738
|
+
`Segment ${i}: lowHzEnd (${seg.lowHzEnd}) must be < highHzEnd (${seg.highHzEnd})`
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
if (seg.startTime >= seg.endTime) {
|
|
742
|
+
errors.push(
|
|
743
|
+
`Segment ${i}: startTime (${seg.startTime}) must be < endTime (${seg.endTime})`
|
|
744
|
+
);
|
|
745
|
+
}
|
|
746
|
+
if (i < segments.length - 1) {
|
|
747
|
+
const next = segments[i + 1];
|
|
748
|
+
if (!next) continue;
|
|
749
|
+
if (seg.endTime > next.startTime) {
|
|
750
|
+
errors.push(`Segment ${i} overlaps with segment ${i + 1}`);
|
|
751
|
+
}
|
|
752
|
+
if (seg.startTime >= next.startTime) {
|
|
753
|
+
errors.push(`Segment ${i} not ordered before segment ${i + 1}`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return errors;
|
|
758
|
+
}
|
|
759
|
+
function validateFrequencyBand(band) {
|
|
760
|
+
const errors = [];
|
|
761
|
+
if (!band.id || band.id.trim() === "") {
|
|
762
|
+
errors.push("Band must have a non-empty id");
|
|
763
|
+
}
|
|
764
|
+
if (!band.label || band.label.trim() === "") {
|
|
765
|
+
errors.push("Band must have a non-empty label");
|
|
766
|
+
}
|
|
767
|
+
if (band.frequencyShape.length === 0) {
|
|
768
|
+
errors.push(`Band "${band.label}": must have at least one frequency segment`);
|
|
769
|
+
}
|
|
770
|
+
const segmentErrors = validateFrequencySegments(band.frequencyShape);
|
|
771
|
+
errors.push(...segmentErrors.map((e) => `Band "${band.label}": ${e}`));
|
|
772
|
+
if (band.timeScope.kind === "sectioned" && band.frequencyShape.length > 0) {
|
|
773
|
+
const { startTime, endTime } = band.timeScope;
|
|
774
|
+
const first = band.frequencyShape[0];
|
|
775
|
+
const last = band.frequencyShape[band.frequencyShape.length - 1];
|
|
776
|
+
if (first && first.startTime > startTime) {
|
|
777
|
+
errors.push(
|
|
778
|
+
`Band "${band.label}": segments don't cover scope start (first segment starts at ${first.startTime}, scope starts at ${startTime})`
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
if (last && last.endTime < endTime) {
|
|
782
|
+
errors.push(
|
|
783
|
+
`Band "${band.label}": segments don't cover scope end (last segment ends at ${last.endTime}, scope ends at ${endTime})`
|
|
784
|
+
);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return errors;
|
|
788
|
+
}
|
|
789
|
+
function validateBandStructure(structure) {
|
|
790
|
+
const errors = [];
|
|
791
|
+
const ids = /* @__PURE__ */ new Set();
|
|
792
|
+
for (const band of structure.bands) {
|
|
793
|
+
if (ids.has(band.id)) {
|
|
794
|
+
errors.push(`Duplicate band ID: ${band.id}`);
|
|
795
|
+
}
|
|
796
|
+
ids.add(band.id);
|
|
797
|
+
}
|
|
798
|
+
for (const band of structure.bands) {
|
|
799
|
+
errors.push(...validateFrequencyBand(band));
|
|
800
|
+
}
|
|
801
|
+
return errors;
|
|
802
|
+
}
|
|
803
|
+
function createBandStructure() {
|
|
804
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
805
|
+
return {
|
|
806
|
+
version: 2,
|
|
807
|
+
bands: [],
|
|
808
|
+
createdAt: now,
|
|
809
|
+
modifiedAt: now
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
function createConstantBand(label, lowHz, highHz, duration, options) {
|
|
813
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
814
|
+
return {
|
|
815
|
+
id: options?.id ?? generateBandId(),
|
|
816
|
+
label,
|
|
817
|
+
sourceId: options?.sourceId ?? "mixdown",
|
|
818
|
+
enabled: options?.enabled ?? true,
|
|
819
|
+
timeScope: { kind: "global" },
|
|
820
|
+
frequencyShape: [
|
|
821
|
+
{
|
|
822
|
+
startTime: 0,
|
|
823
|
+
endTime: duration,
|
|
824
|
+
lowHzStart: lowHz,
|
|
825
|
+
highHzStart: highHz,
|
|
826
|
+
lowHzEnd: lowHz,
|
|
827
|
+
highHzEnd: highHz
|
|
828
|
+
}
|
|
829
|
+
],
|
|
830
|
+
sortOrder: options?.sortOrder ?? 0,
|
|
831
|
+
provenance: {
|
|
832
|
+
source: "manual",
|
|
833
|
+
createdAt: now
|
|
834
|
+
}
|
|
835
|
+
};
|
|
836
|
+
}
|
|
837
|
+
function createSectionedBand(label, lowHz, highHz, startTime, endTime, options) {
|
|
838
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
839
|
+
return {
|
|
840
|
+
id: options?.id ?? generateBandId(),
|
|
841
|
+
label,
|
|
842
|
+
sourceId: options?.sourceId ?? "mixdown",
|
|
843
|
+
enabled: options?.enabled ?? true,
|
|
844
|
+
timeScope: { kind: "sectioned", startTime, endTime },
|
|
845
|
+
frequencyShape: [
|
|
846
|
+
{
|
|
847
|
+
startTime,
|
|
848
|
+
endTime,
|
|
849
|
+
lowHzStart: lowHz,
|
|
850
|
+
highHzStart: highHz,
|
|
851
|
+
lowHzEnd: lowHz,
|
|
852
|
+
highHzEnd: highHz
|
|
853
|
+
}
|
|
854
|
+
],
|
|
855
|
+
sortOrder: options?.sortOrder ?? 0,
|
|
856
|
+
provenance: {
|
|
857
|
+
source: "manual",
|
|
858
|
+
createdAt: now
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
function createStandardBands(duration, sourceId = "mixdown") {
|
|
863
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
864
|
+
const bands = [
|
|
865
|
+
{ label: "Sub Bass", lowHz: 20, highHz: 60 },
|
|
866
|
+
{ label: "Bass", lowHz: 60, highHz: 250 },
|
|
867
|
+
{ label: "Low Mids", lowHz: 250, highHz: 500 },
|
|
868
|
+
{ label: "Mids", lowHz: 500, highHz: 2e3 },
|
|
869
|
+
{ label: "High Mids", lowHz: 2e3, highHz: 4e3 },
|
|
870
|
+
{ label: "Highs", lowHz: 4e3, highHz: 2e4 }
|
|
871
|
+
];
|
|
872
|
+
return bands.map((b, index) => ({
|
|
873
|
+
id: generateBandId(),
|
|
874
|
+
label: b.label,
|
|
875
|
+
sourceId,
|
|
876
|
+
enabled: true,
|
|
877
|
+
timeScope: { kind: "global" },
|
|
878
|
+
frequencyShape: [
|
|
879
|
+
{
|
|
880
|
+
startTime: 0,
|
|
881
|
+
endTime: duration,
|
|
882
|
+
lowHzStart: b.lowHz,
|
|
883
|
+
highHzStart: b.highHz,
|
|
884
|
+
lowHzEnd: b.lowHz,
|
|
885
|
+
highHzEnd: b.highHz
|
|
886
|
+
}
|
|
887
|
+
],
|
|
888
|
+
sortOrder: index,
|
|
889
|
+
provenance: {
|
|
890
|
+
source: "preset",
|
|
891
|
+
createdAt: now,
|
|
892
|
+
presetName: "Standard 6-Band"
|
|
893
|
+
}
|
|
894
|
+
}));
|
|
895
|
+
}
|
|
896
|
+
function bandsActiveAt(structure, time, sourceId) {
|
|
897
|
+
if (!structure) return [];
|
|
898
|
+
return structure.bands.filter((band) => {
|
|
899
|
+
if (!band.enabled) return false;
|
|
900
|
+
if (sourceId !== void 0 && band.sourceId !== sourceId) return false;
|
|
901
|
+
if (band.timeScope.kind === "global") return true;
|
|
902
|
+
return time >= band.timeScope.startTime && time < band.timeScope.endTime;
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
function frequencyBoundsAt(band, time) {
|
|
906
|
+
if (!band.enabled) return null;
|
|
907
|
+
if (band.timeScope.kind === "sectioned") {
|
|
908
|
+
if (time < band.timeScope.startTime || time >= band.timeScope.endTime) {
|
|
909
|
+
return null;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
for (const seg of band.frequencyShape) {
|
|
913
|
+
if (time >= seg.startTime && time < seg.endTime) {
|
|
914
|
+
const t = (time - seg.startTime) / (seg.endTime - seg.startTime);
|
|
915
|
+
return {
|
|
916
|
+
bandId: band.id,
|
|
917
|
+
lowHz: seg.lowHzStart + (seg.lowHzEnd - seg.lowHzStart) * t,
|
|
918
|
+
highHz: seg.highHzStart + (seg.highHzEnd - seg.highHzStart) * t,
|
|
919
|
+
enabled: band.enabled
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
const last = band.frequencyShape[band.frequencyShape.length - 1];
|
|
924
|
+
if (last && Math.abs(time - last.endTime) < 1e-3) {
|
|
925
|
+
return {
|
|
926
|
+
bandId: band.id,
|
|
927
|
+
lowHz: last.lowHzEnd,
|
|
928
|
+
highHz: last.highHzEnd,
|
|
929
|
+
enabled: band.enabled
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
function allFrequencyBoundsAt(structure, time) {
|
|
935
|
+
if (!structure) return [];
|
|
936
|
+
const bounds = [];
|
|
937
|
+
for (const band of structure.bands) {
|
|
938
|
+
const b = frequencyBoundsAt(band, time);
|
|
939
|
+
if (b) bounds.push(b);
|
|
940
|
+
}
|
|
941
|
+
bounds.sort((a, b) => {
|
|
942
|
+
const bandA = structure.bands.find((band) => band.id === a.bandId);
|
|
943
|
+
const bandB = structure.bands.find((band) => band.id === b.bandId);
|
|
944
|
+
return (bandA?.sortOrder ?? 0) - (bandB?.sortOrder ?? 0);
|
|
945
|
+
});
|
|
946
|
+
return bounds;
|
|
947
|
+
}
|
|
948
|
+
function findBandById(structure, id) {
|
|
949
|
+
if (!structure) return null;
|
|
950
|
+
return structure.bands.find((b) => b.id === id) ?? null;
|
|
951
|
+
}
|
|
952
|
+
function sortBands(bands) {
|
|
953
|
+
return [...bands].sort((a, b) => a.sortOrder - b.sortOrder);
|
|
954
|
+
}
|
|
955
|
+
function sortFrequencySegments(segments) {
|
|
956
|
+
return [...segments].sort((a, b) => a.startTime - b.startTime);
|
|
957
|
+
}
|
|
958
|
+
function touchStructure(structure) {
|
|
959
|
+
return {
|
|
960
|
+
...structure,
|
|
961
|
+
modifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
function addBandToStructure(structure, band) {
|
|
965
|
+
return {
|
|
966
|
+
...structure,
|
|
967
|
+
bands: sortBands([...structure.bands, band]),
|
|
968
|
+
modifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
function removeBandFromStructure(structure, bandId) {
|
|
972
|
+
return {
|
|
973
|
+
...structure,
|
|
974
|
+
bands: structure.bands.filter((b) => b.id !== bandId),
|
|
975
|
+
modifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
976
|
+
};
|
|
977
|
+
}
|
|
978
|
+
function updateBandInStructure(structure, bandId, updates) {
|
|
979
|
+
return {
|
|
980
|
+
...structure,
|
|
981
|
+
bands: sortBands(
|
|
982
|
+
structure.bands.map((b) => b.id === bandId ? { ...b, ...updates } : b)
|
|
983
|
+
),
|
|
984
|
+
modifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
985
|
+
};
|
|
986
|
+
}
|
|
987
|
+
function keyframesFromBand(band) {
|
|
988
|
+
const keyframes = [];
|
|
989
|
+
const segments = sortFrequencySegments(band.frequencyShape);
|
|
990
|
+
for (let i = 0; i < segments.length; i++) {
|
|
991
|
+
const seg = segments[i];
|
|
992
|
+
if (!seg) continue;
|
|
993
|
+
keyframes.push({
|
|
994
|
+
time: seg.startTime,
|
|
995
|
+
lowHz: seg.lowHzStart,
|
|
996
|
+
highHz: seg.highHzStart,
|
|
997
|
+
segmentIndex: i,
|
|
998
|
+
edge: "start"
|
|
999
|
+
});
|
|
1000
|
+
const nextSeg = segments[i + 1];
|
|
1001
|
+
const isShared = nextSeg && Math.abs(nextSeg.startTime - seg.endTime) < 1e-3;
|
|
1002
|
+
if (!isShared) {
|
|
1003
|
+
keyframes.push({
|
|
1004
|
+
time: seg.endTime,
|
|
1005
|
+
lowHz: seg.lowHzEnd,
|
|
1006
|
+
highHz: seg.highHzEnd,
|
|
1007
|
+
segmentIndex: i,
|
|
1008
|
+
edge: "end"
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
return keyframes;
|
|
1013
|
+
}
|
|
1014
|
+
function segmentsFromKeyframes(keyframes) {
|
|
1015
|
+
if (keyframes.length < 2) return [];
|
|
1016
|
+
const sorted = [...keyframes].sort((a, b) => a.time - b.time);
|
|
1017
|
+
const segments = [];
|
|
1018
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
1019
|
+
const curr = sorted[i];
|
|
1020
|
+
const next = sorted[i + 1];
|
|
1021
|
+
if (!curr || !next) continue;
|
|
1022
|
+
segments.push({
|
|
1023
|
+
startTime: curr.time,
|
|
1024
|
+
endTime: next.time,
|
|
1025
|
+
lowHzStart: curr.lowHz,
|
|
1026
|
+
highHzStart: curr.highHz,
|
|
1027
|
+
lowHzEnd: next.lowHz,
|
|
1028
|
+
highHzEnd: next.highHz
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
return segments;
|
|
1032
|
+
}
|
|
1033
|
+
function splitBandSegmentAt(band, time) {
|
|
1034
|
+
const segments = sortFrequencySegments(band.frequencyShape);
|
|
1035
|
+
const segmentIndex = segments.findIndex(
|
|
1036
|
+
(seg2) => time > seg2.startTime && time < seg2.endTime
|
|
1037
|
+
);
|
|
1038
|
+
if (segmentIndex === -1) {
|
|
1039
|
+
return band;
|
|
1040
|
+
}
|
|
1041
|
+
const seg = segments[segmentIndex];
|
|
1042
|
+
if (!seg) return band;
|
|
1043
|
+
const t = (time - seg.startTime) / (seg.endTime - seg.startTime);
|
|
1044
|
+
const lowHz = seg.lowHzStart + (seg.lowHzEnd - seg.lowHzStart) * t;
|
|
1045
|
+
const highHz = seg.highHzStart + (seg.highHzEnd - seg.highHzStart) * t;
|
|
1046
|
+
const firstHalf = {
|
|
1047
|
+
startTime: seg.startTime,
|
|
1048
|
+
endTime: time,
|
|
1049
|
+
lowHzStart: seg.lowHzStart,
|
|
1050
|
+
highHzStart: seg.highHzStart,
|
|
1051
|
+
lowHzEnd: lowHz,
|
|
1052
|
+
highHzEnd: highHz
|
|
1053
|
+
};
|
|
1054
|
+
const secondHalf = {
|
|
1055
|
+
startTime: time,
|
|
1056
|
+
endTime: seg.endTime,
|
|
1057
|
+
lowHzStart: lowHz,
|
|
1058
|
+
highHzStart: highHz,
|
|
1059
|
+
lowHzEnd: seg.lowHzEnd,
|
|
1060
|
+
highHzEnd: seg.highHzEnd
|
|
1061
|
+
};
|
|
1062
|
+
const newSegments = [
|
|
1063
|
+
...segments.slice(0, segmentIndex),
|
|
1064
|
+
firstHalf,
|
|
1065
|
+
secondHalf,
|
|
1066
|
+
...segments.slice(segmentIndex + 1)
|
|
1067
|
+
];
|
|
1068
|
+
return {
|
|
1069
|
+
...band,
|
|
1070
|
+
frequencyShape: newSegments
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
function mergeAdjacentSegments(band, tolerance = 0.1) {
|
|
1074
|
+
const segments = sortFrequencySegments(band.frequencyShape);
|
|
1075
|
+
if (segments.length < 2) return band;
|
|
1076
|
+
const merged = [];
|
|
1077
|
+
let current = segments[0];
|
|
1078
|
+
if (!current) return band;
|
|
1079
|
+
for (let i = 1; i < segments.length; i++) {
|
|
1080
|
+
const next = segments[i];
|
|
1081
|
+
if (!next) continue;
|
|
1082
|
+
const timeMatches = Math.abs(current.endTime - next.startTime) < 1e-3;
|
|
1083
|
+
const lowMatches = Math.abs(current.lowHzEnd - next.lowHzStart) < tolerance;
|
|
1084
|
+
const highMatches = Math.abs(current.highHzEnd - next.highHzStart) < tolerance;
|
|
1085
|
+
if (timeMatches && lowMatches && highMatches) {
|
|
1086
|
+
current = {
|
|
1087
|
+
startTime: current.startTime,
|
|
1088
|
+
endTime: next.endTime,
|
|
1089
|
+
lowHzStart: current.lowHzStart,
|
|
1090
|
+
highHzStart: current.highHzStart,
|
|
1091
|
+
lowHzEnd: next.lowHzEnd,
|
|
1092
|
+
highHzEnd: next.highHzEnd
|
|
1093
|
+
};
|
|
1094
|
+
} else {
|
|
1095
|
+
merged.push(current);
|
|
1096
|
+
current = next;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
merged.push(current);
|
|
1100
|
+
return {
|
|
1101
|
+
...band,
|
|
1102
|
+
frequencyShape: merged
|
|
1103
|
+
};
|
|
1104
|
+
}
|
|
1105
|
+
function removeKeyframe(band, time) {
|
|
1106
|
+
const segments = sortFrequencySegments(band.frequencyShape);
|
|
1107
|
+
if (segments.length < 2) return band;
|
|
1108
|
+
const endingIndex = segments.findIndex(
|
|
1109
|
+
(seg) => Math.abs(seg.endTime - time) < 1e-3
|
|
1110
|
+
);
|
|
1111
|
+
const startingIndex = segments.findIndex(
|
|
1112
|
+
(seg) => Math.abs(seg.startTime - time) < 1e-3
|
|
1113
|
+
);
|
|
1114
|
+
if (endingIndex === -1 || startingIndex === -1) return band;
|
|
1115
|
+
if (endingIndex !== startingIndex - 1) return band;
|
|
1116
|
+
const endingSeg = segments[endingIndex];
|
|
1117
|
+
const startingSeg = segments[startingIndex];
|
|
1118
|
+
if (!endingSeg || !startingSeg) return band;
|
|
1119
|
+
const merged = {
|
|
1120
|
+
startTime: endingSeg.startTime,
|
|
1121
|
+
endTime: startingSeg.endTime,
|
|
1122
|
+
lowHzStart: endingSeg.lowHzStart,
|
|
1123
|
+
highHzStart: endingSeg.highHzStart,
|
|
1124
|
+
lowHzEnd: startingSeg.lowHzEnd,
|
|
1125
|
+
highHzEnd: startingSeg.highHzEnd
|
|
1126
|
+
};
|
|
1127
|
+
const newSegments = [
|
|
1128
|
+
...segments.slice(0, endingIndex),
|
|
1129
|
+
merged,
|
|
1130
|
+
...segments.slice(startingIndex + 1)
|
|
1131
|
+
];
|
|
1132
|
+
return {
|
|
1133
|
+
...band,
|
|
1134
|
+
frequencyShape: newSegments
|
|
1135
|
+
};
|
|
1136
|
+
}
|
|
1137
|
+
function updateKeyframe(band, time, lowHz, highHz) {
|
|
1138
|
+
const segments = band.frequencyShape.map((seg) => {
|
|
1139
|
+
const isStart = Math.abs(seg.startTime - time) < 1e-3;
|
|
1140
|
+
const isEnd = Math.abs(seg.endTime - time) < 1e-3;
|
|
1141
|
+
if (!isStart && !isEnd) return seg;
|
|
1142
|
+
const newSeg = { ...seg };
|
|
1143
|
+
if (isStart) {
|
|
1144
|
+
if (lowHz !== void 0) newSeg.lowHzStart = lowHz;
|
|
1145
|
+
if (highHz !== void 0) newSeg.highHzStart = highHz;
|
|
1146
|
+
}
|
|
1147
|
+
if (isEnd) {
|
|
1148
|
+
if (lowHz !== void 0) newSeg.lowHzEnd = lowHz;
|
|
1149
|
+
if (highHz !== void 0) newSeg.highHzEnd = highHz;
|
|
1150
|
+
}
|
|
1151
|
+
return newSeg;
|
|
1152
|
+
});
|
|
1153
|
+
return {
|
|
1154
|
+
...band,
|
|
1155
|
+
frequencyShape: segments
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
function moveKeyframeTime(band, oldTime, newTime) {
|
|
1159
|
+
const segments = sortFrequencySegments(band.frequencyShape);
|
|
1160
|
+
const endingIndex = segments.findIndex(
|
|
1161
|
+
(seg) => Math.abs(seg.endTime - oldTime) < 1e-3
|
|
1162
|
+
);
|
|
1163
|
+
const startingIndex = segments.findIndex(
|
|
1164
|
+
(seg) => Math.abs(seg.startTime - oldTime) < 1e-3
|
|
1165
|
+
);
|
|
1166
|
+
if (endingIndex === -1 || startingIndex === -1) return band;
|
|
1167
|
+
if (endingIndex !== startingIndex - 1) return band;
|
|
1168
|
+
const endingSeg = segments[endingIndex];
|
|
1169
|
+
const startingSeg = segments[startingIndex];
|
|
1170
|
+
if (!endingSeg || !startingSeg) return band;
|
|
1171
|
+
if (newTime <= endingSeg.startTime || newTime >= startingSeg.endTime) {
|
|
1172
|
+
return band;
|
|
1173
|
+
}
|
|
1174
|
+
const newSegments = segments.map((seg, i) => {
|
|
1175
|
+
if (i === endingIndex) {
|
|
1176
|
+
return { ...seg, endTime: newTime };
|
|
1177
|
+
}
|
|
1178
|
+
if (i === startingIndex) {
|
|
1179
|
+
return { ...seg, startTime: newTime };
|
|
1180
|
+
}
|
|
1181
|
+
return seg;
|
|
1182
|
+
});
|
|
1183
|
+
return {
|
|
1184
|
+
...band,
|
|
1185
|
+
frequencyShape: newSegments
|
|
1186
|
+
};
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// src/dsp/bandMask.ts
|
|
1190
|
+
function binToHz(bin, sampleRate, fftSize) {
|
|
1191
|
+
return bin * (sampleRate / fftSize);
|
|
1192
|
+
}
|
|
1193
|
+
function hzToBin(hz, sampleRate, fftSize) {
|
|
1194
|
+
return hz / (sampleRate / fftSize);
|
|
1195
|
+
}
|
|
1196
|
+
function computeBandMaskAtTime(band, time, sampleRate, fftSize, options) {
|
|
1197
|
+
const bounds = frequencyBoundsAt(band, time);
|
|
1198
|
+
if (!bounds) return null;
|
|
1199
|
+
const nBins = (fftSize >>> 1) + 1;
|
|
1200
|
+
const mask = new Float32Array(nBins);
|
|
1201
|
+
const edgeSmoothHz = options?.edgeSmoothHz ?? 0;
|
|
1202
|
+
for (let k = 0; k < nBins; k++) {
|
|
1203
|
+
const hz = binToHz(k, sampleRate, fftSize);
|
|
1204
|
+
if (hz < bounds.lowHz || hz > bounds.highHz) {
|
|
1205
|
+
mask[k] = 0;
|
|
1206
|
+
} else if (edgeSmoothHz <= 0) {
|
|
1207
|
+
mask[k] = 1;
|
|
1208
|
+
} else {
|
|
1209
|
+
const distFromLow = hz - bounds.lowHz;
|
|
1210
|
+
const distFromHigh = bounds.highHz - hz;
|
|
1211
|
+
let gain = 1;
|
|
1212
|
+
if (distFromLow < edgeSmoothHz) {
|
|
1213
|
+
gain *= 0.5 * (1 - Math.cos(Math.PI * distFromLow / edgeSmoothHz));
|
|
1214
|
+
}
|
|
1215
|
+
if (distFromHigh < edgeSmoothHz) {
|
|
1216
|
+
gain *= 0.5 * (1 - Math.cos(Math.PI * distFromHigh / edgeSmoothHz));
|
|
1217
|
+
}
|
|
1218
|
+
mask[k] = gain;
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
return mask;
|
|
1222
|
+
}
|
|
1223
|
+
function applyBandMaskToSpectrogram(spec, band, options) {
|
|
1224
|
+
const nFrames = spec.times.length;
|
|
1225
|
+
const nBins = (spec.fftSize >>> 1) + 1;
|
|
1226
|
+
const maskedMagnitudes = new Array(nFrames);
|
|
1227
|
+
const energyRetained = new Float32Array(nFrames);
|
|
1228
|
+
for (let t = 0; t < nFrames; t++) {
|
|
1229
|
+
const time = spec.times[t] ?? 0;
|
|
1230
|
+
const srcMags = spec.magnitudes[t];
|
|
1231
|
+
if (!srcMags) {
|
|
1232
|
+
maskedMagnitudes[t] = new Float32Array(nBins);
|
|
1233
|
+
energyRetained[t] = 0;
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
const mask = computeBandMaskAtTime(
|
|
1237
|
+
band,
|
|
1238
|
+
time,
|
|
1239
|
+
spec.sampleRate,
|
|
1240
|
+
spec.fftSize,
|
|
1241
|
+
options
|
|
1242
|
+
);
|
|
1243
|
+
if (!mask) {
|
|
1244
|
+
maskedMagnitudes[t] = new Float32Array(nBins);
|
|
1245
|
+
energyRetained[t] = 0;
|
|
1246
|
+
continue;
|
|
1247
|
+
}
|
|
1248
|
+
const masked = new Float32Array(nBins);
|
|
1249
|
+
let originalEnergy = 0;
|
|
1250
|
+
let maskedEnergy = 0;
|
|
1251
|
+
for (let k = 0; k < nBins; k++) {
|
|
1252
|
+
const mag = srcMags[k] ?? 0;
|
|
1253
|
+
const maskedMag = mag * (mask[k] ?? 0);
|
|
1254
|
+
masked[k] = maskedMag;
|
|
1255
|
+
originalEnergy += mag * mag;
|
|
1256
|
+
maskedEnergy += maskedMag * maskedMag;
|
|
1257
|
+
}
|
|
1258
|
+
maskedMagnitudes[t] = masked;
|
|
1259
|
+
energyRetained[t] = originalEnergy > 0 ? maskedEnergy / originalEnergy : 0;
|
|
1260
|
+
}
|
|
1261
|
+
return {
|
|
1262
|
+
sampleRate: spec.sampleRate,
|
|
1263
|
+
fftSize: spec.fftSize,
|
|
1264
|
+
hopSize: spec.hopSize,
|
|
1265
|
+
times: spec.times,
|
|
1266
|
+
magnitudes: maskedMagnitudes,
|
|
1267
|
+
bandId: band.id,
|
|
1268
|
+
energyRetainedPerFrame: energyRetained
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
function computeFrameEnergy(magnitudes) {
|
|
1272
|
+
let energy = 0;
|
|
1273
|
+
for (let k = 0; k < magnitudes.length; k++) {
|
|
1274
|
+
const mag = magnitudes[k] ?? 0;
|
|
1275
|
+
energy += mag * mag;
|
|
1276
|
+
}
|
|
1277
|
+
return energy;
|
|
1278
|
+
}
|
|
1279
|
+
function computeFrameAmplitude(magnitudes) {
|
|
1280
|
+
let sum = 0;
|
|
1281
|
+
for (let k = 0; k < magnitudes.length; k++) {
|
|
1282
|
+
sum += magnitudes[k] ?? 0;
|
|
1283
|
+
}
|
|
1284
|
+
return sum;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
// src/dsp/bandMir.ts
|
|
1288
|
+
function computeDiagnostics(energyRetainedPerFrame) {
|
|
1289
|
+
const totalFrames = energyRetainedPerFrame.length;
|
|
1290
|
+
let sum = 0;
|
|
1291
|
+
let weakCount = 0;
|
|
1292
|
+
let emptyCount = 0;
|
|
1293
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
1294
|
+
const e = energyRetainedPerFrame[i] ?? 0;
|
|
1295
|
+
sum += e;
|
|
1296
|
+
if (e < 0.01) weakCount++;
|
|
1297
|
+
if (e === 0) emptyCount++;
|
|
1298
|
+
}
|
|
1299
|
+
const meanEnergyRetained = totalFrames > 0 ? sum / totalFrames : 0;
|
|
1300
|
+
const warnings = [];
|
|
1301
|
+
if (meanEnergyRetained < 0.01) {
|
|
1302
|
+
warnings.push("Band contains very little energy - check frequency range");
|
|
1303
|
+
}
|
|
1304
|
+
if (emptyCount > totalFrames * 0.5) {
|
|
1305
|
+
warnings.push("More than half of frames are empty - band may not be active for this audio");
|
|
1306
|
+
}
|
|
1307
|
+
return {
|
|
1308
|
+
meanEnergyRetained,
|
|
1309
|
+
weakFrameCount: weakCount,
|
|
1310
|
+
emptyFrameCount: emptyCount,
|
|
1311
|
+
totalFrames,
|
|
1312
|
+
warnings
|
|
1313
|
+
};
|
|
1314
|
+
}
|
|
1315
|
+
function createMeta(startMs) {
|
|
1316
|
+
const endMs = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1317
|
+
const timings = {
|
|
1318
|
+
totalMs: endMs - startMs,
|
|
1319
|
+
cpuMs: endMs - startMs,
|
|
1320
|
+
gpuMs: 0
|
|
1321
|
+
};
|
|
1322
|
+
return {
|
|
1323
|
+
backend: "cpu",
|
|
1324
|
+
usedGpu: false,
|
|
1325
|
+
timings
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
function movingAverage(values, windowFrames) {
|
|
1329
|
+
if (windowFrames <= 1) return values;
|
|
1330
|
+
const n = values.length;
|
|
1331
|
+
const out = new Float32Array(n);
|
|
1332
|
+
const half = Math.floor(windowFrames / 2);
|
|
1333
|
+
const prefix = new Float64Array(n + 1);
|
|
1334
|
+
prefix[0] = 0;
|
|
1335
|
+
for (let i = 0; i < n; i++) {
|
|
1336
|
+
prefix[i + 1] = (prefix[i] ?? 0) + (values[i] ?? 0);
|
|
1337
|
+
}
|
|
1338
|
+
for (let i = 0; i < n; i++) {
|
|
1339
|
+
const start = Math.max(0, i - half);
|
|
1340
|
+
const end = Math.min(n, i + half + 1);
|
|
1341
|
+
const sum = (prefix[end] ?? 0) - (prefix[start] ?? 0);
|
|
1342
|
+
const count = Math.max(1, end - start);
|
|
1343
|
+
out[i] = sum / count;
|
|
1344
|
+
}
|
|
1345
|
+
return out;
|
|
1346
|
+
}
|
|
1347
|
+
function logCompress(x) {
|
|
1348
|
+
return Math.log1p(Math.max(0, x));
|
|
1349
|
+
}
|
|
1350
|
+
function bandAmplitudeEnvelope(spec, band, options) {
|
|
1351
|
+
const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1352
|
+
const masked = applyBandMaskToSpectrogram(spec, band, {
|
|
1353
|
+
edgeSmoothHz: options?.edgeSmoothHz
|
|
1354
|
+
});
|
|
1355
|
+
const nFrames = masked.times.length;
|
|
1356
|
+
const values = new Float32Array(nFrames);
|
|
1357
|
+
for (let t = 0; t < nFrames; t++) {
|
|
1358
|
+
const mags = masked.magnitudes[t];
|
|
1359
|
+
if (mags) {
|
|
1360
|
+
values[t] = computeFrameAmplitude(mags);
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return {
|
|
1364
|
+
kind: "bandMir1d",
|
|
1365
|
+
bandId: band.id,
|
|
1366
|
+
bandLabel: band.label,
|
|
1367
|
+
fn: "bandAmplitudeEnvelope",
|
|
1368
|
+
times: masked.times,
|
|
1369
|
+
values,
|
|
1370
|
+
meta: createMeta(startMs),
|
|
1371
|
+
diagnostics: computeDiagnostics(masked.energyRetainedPerFrame)
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
function bandOnsetStrength(spec, band, options) {
|
|
1375
|
+
const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1376
|
+
const useLog = options?.onset?.useLog ?? false;
|
|
1377
|
+
const smoothMs = options?.onset?.smoothMs ?? 30;
|
|
1378
|
+
const diffMethod = options?.onset?.diffMethod ?? "rectified";
|
|
1379
|
+
const masked = applyBandMaskToSpectrogram(spec, band, {
|
|
1380
|
+
edgeSmoothHz: options?.edgeSmoothHz
|
|
1381
|
+
});
|
|
1382
|
+
const nFrames = masked.times.length;
|
|
1383
|
+
const nBins = (masked.fftSize >>> 1) + 1;
|
|
1384
|
+
const out = new Float32Array(nFrames);
|
|
1385
|
+
out[0] = 0;
|
|
1386
|
+
for (let t = 1; t < nFrames; t++) {
|
|
1387
|
+
const cur = masked.magnitudes[t];
|
|
1388
|
+
const prev = masked.magnitudes[t - 1];
|
|
1389
|
+
if (!cur || !prev) {
|
|
1390
|
+
out[t] = 0;
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
let sum = 0;
|
|
1394
|
+
let binsWithData = 0;
|
|
1395
|
+
for (let k = 0; k < nBins; k++) {
|
|
1396
|
+
let a = cur[k] ?? 0;
|
|
1397
|
+
let b = prev[k] ?? 0;
|
|
1398
|
+
if (a > 0 || b > 0) {
|
|
1399
|
+
binsWithData++;
|
|
1400
|
+
if (useLog) {
|
|
1401
|
+
a = logCompress(a);
|
|
1402
|
+
b = logCompress(b);
|
|
1403
|
+
}
|
|
1404
|
+
const d = a - b;
|
|
1405
|
+
sum += diffMethod === "abs" ? Math.abs(d) : Math.max(0, d);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
out[t] = binsWithData > 0 ? sum / binsWithData : 0;
|
|
1409
|
+
}
|
|
1410
|
+
const smoothMs_ = smoothMs;
|
|
1411
|
+
if (smoothMs_ > 0 && nFrames >= 2) {
|
|
1412
|
+
const dt = (masked.times[1] ?? 0) - (masked.times[0] ?? 0);
|
|
1413
|
+
const windowFrames = Math.max(1, Math.round(smoothMs_ / 1e3 / Math.max(1e-9, dt)));
|
|
1414
|
+
return {
|
|
1415
|
+
kind: "bandMir1d",
|
|
1416
|
+
bandId: band.id,
|
|
1417
|
+
bandLabel: band.label,
|
|
1418
|
+
fn: "bandOnsetStrength",
|
|
1419
|
+
times: masked.times,
|
|
1420
|
+
values: movingAverage(out, windowFrames | 1),
|
|
1421
|
+
meta: createMeta(startMs),
|
|
1422
|
+
diagnostics: computeDiagnostics(masked.energyRetainedPerFrame)
|
|
1423
|
+
};
|
|
1424
|
+
}
|
|
1425
|
+
return {
|
|
1426
|
+
kind: "bandMir1d",
|
|
1427
|
+
bandId: band.id,
|
|
1428
|
+
bandLabel: band.label,
|
|
1429
|
+
fn: "bandOnsetStrength",
|
|
1430
|
+
times: masked.times,
|
|
1431
|
+
values: out,
|
|
1432
|
+
meta: createMeta(startMs),
|
|
1433
|
+
diagnostics: computeDiagnostics(masked.energyRetainedPerFrame)
|
|
1434
|
+
};
|
|
1435
|
+
}
|
|
1436
|
+
function bandSpectralFlux(spec, band, options) {
|
|
1437
|
+
const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1438
|
+
const masked = applyBandMaskToSpectrogram(spec, band, {
|
|
1439
|
+
edgeSmoothHz: options?.edgeSmoothHz
|
|
1440
|
+
});
|
|
1441
|
+
const nFrames = masked.times.length;
|
|
1442
|
+
const nBins = (masked.fftSize >>> 1) + 1;
|
|
1443
|
+
const out = new Float32Array(nFrames);
|
|
1444
|
+
let prev = null;
|
|
1445
|
+
for (let t = 0; t < nFrames; t++) {
|
|
1446
|
+
const mags = masked.magnitudes[t];
|
|
1447
|
+
if (!mags) {
|
|
1448
|
+
out[t] = 0;
|
|
1449
|
+
prev = null;
|
|
1450
|
+
continue;
|
|
1451
|
+
}
|
|
1452
|
+
let sum = 0;
|
|
1453
|
+
for (let k = 0; k < nBins; k++) sum += mags[k] ?? 0;
|
|
1454
|
+
if (sum <= 0) {
|
|
1455
|
+
out[t] = 0;
|
|
1456
|
+
prev = null;
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
const cur = new Float32Array(nBins);
|
|
1460
|
+
const inv = 1 / sum;
|
|
1461
|
+
for (let k = 0; k < nBins; k++) cur[k] = (mags[k] ?? 0) * inv;
|
|
1462
|
+
if (!prev) {
|
|
1463
|
+
out[t] = 0;
|
|
1464
|
+
prev = cur;
|
|
1465
|
+
continue;
|
|
1466
|
+
}
|
|
1467
|
+
let flux = 0;
|
|
1468
|
+
for (let k = 0; k < nBins; k++) {
|
|
1469
|
+
const d = (cur[k] ?? 0) - (prev[k] ?? 0);
|
|
1470
|
+
flux += Math.abs(d);
|
|
1471
|
+
}
|
|
1472
|
+
out[t] = flux;
|
|
1473
|
+
prev = cur;
|
|
1474
|
+
}
|
|
1475
|
+
return {
|
|
1476
|
+
kind: "bandMir1d",
|
|
1477
|
+
bandId: band.id,
|
|
1478
|
+
bandLabel: band.label,
|
|
1479
|
+
fn: "bandSpectralFlux",
|
|
1480
|
+
times: masked.times,
|
|
1481
|
+
values: out,
|
|
1482
|
+
meta: createMeta(startMs),
|
|
1483
|
+
diagnostics: computeDiagnostics(masked.energyRetainedPerFrame)
|
|
1484
|
+
};
|
|
1485
|
+
}
|
|
1486
|
+
function bandSpectralCentroid(spec, band, options) {
|
|
1487
|
+
const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1488
|
+
const masked = applyBandMaskToSpectrogram(spec, band, {
|
|
1489
|
+
edgeSmoothHz: options?.edgeSmoothHz
|
|
1490
|
+
});
|
|
1491
|
+
const nFrames = masked.times.length;
|
|
1492
|
+
const nBins = (masked.fftSize >>> 1) + 1;
|
|
1493
|
+
const binHz = masked.sampleRate / masked.fftSize;
|
|
1494
|
+
const out = new Float32Array(nFrames);
|
|
1495
|
+
for (let t = 0; t < nFrames; t++) {
|
|
1496
|
+
const mags = masked.magnitudes[t];
|
|
1497
|
+
if (!mags) {
|
|
1498
|
+
out[t] = 0;
|
|
1499
|
+
continue;
|
|
1500
|
+
}
|
|
1501
|
+
let num = 0;
|
|
1502
|
+
let den = 0;
|
|
1503
|
+
for (let k = 0; k < nBins; k++) {
|
|
1504
|
+
const m = mags[k] ?? 0;
|
|
1505
|
+
if (m > 0) {
|
|
1506
|
+
const f = k * binHz;
|
|
1507
|
+
num += f * m;
|
|
1508
|
+
den += m;
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
out[t] = den > 0 ? num / den : 0;
|
|
1512
|
+
}
|
|
1513
|
+
return {
|
|
1514
|
+
kind: "bandMir1d",
|
|
1515
|
+
bandId: band.id,
|
|
1516
|
+
bandLabel: band.label,
|
|
1517
|
+
fn: "bandSpectralCentroid",
|
|
1518
|
+
times: masked.times,
|
|
1519
|
+
values: out,
|
|
1520
|
+
meta: createMeta(startMs),
|
|
1521
|
+
diagnostics: computeDiagnostics(masked.energyRetainedPerFrame)
|
|
1522
|
+
};
|
|
1523
|
+
}
|
|
1524
|
+
async function runBandMirBatch(spec, request, options) {
|
|
1525
|
+
const startMs = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1526
|
+
const results = /* @__PURE__ */ new Map();
|
|
1527
|
+
for (const band of request.bands) {
|
|
1528
|
+
if (options?.isCancelled?.()) {
|
|
1529
|
+
throw new Error("@octoseq/mir: cancelled");
|
|
1530
|
+
}
|
|
1531
|
+
if (!band.enabled) continue;
|
|
1532
|
+
const bandResults = [];
|
|
1533
|
+
for (const fn of request.functions) {
|
|
1534
|
+
if (options?.isCancelled?.()) {
|
|
1535
|
+
throw new Error("@octoseq/mir: cancelled");
|
|
1536
|
+
}
|
|
1537
|
+
let result;
|
|
1538
|
+
switch (fn) {
|
|
1539
|
+
case "bandAmplitudeEnvelope":
|
|
1540
|
+
result = bandAmplitudeEnvelope(spec, band, options);
|
|
1541
|
+
break;
|
|
1542
|
+
case "bandOnsetStrength":
|
|
1543
|
+
result = bandOnsetStrength(spec, band, options);
|
|
1544
|
+
break;
|
|
1545
|
+
case "bandSpectralFlux":
|
|
1546
|
+
result = bandSpectralFlux(spec, band, options);
|
|
1547
|
+
break;
|
|
1548
|
+
case "bandSpectralCentroid":
|
|
1549
|
+
result = bandSpectralCentroid(spec, band, options);
|
|
1550
|
+
break;
|
|
1551
|
+
default:
|
|
1552
|
+
const _exhaustive = fn;
|
|
1553
|
+
throw new Error(`Unknown band MIR function: ${_exhaustive}`);
|
|
1554
|
+
}
|
|
1555
|
+
bandResults.push(result);
|
|
1556
|
+
}
|
|
1557
|
+
results.set(band.id, bandResults);
|
|
1558
|
+
}
|
|
1559
|
+
const endMs = typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1560
|
+
return {
|
|
1561
|
+
results,
|
|
1562
|
+
totalTimingMs: endMs - startMs
|
|
1563
|
+
};
|
|
1564
|
+
}
|
|
1565
|
+
function getBandMirFunctionLabel(fn) {
|
|
1566
|
+
switch (fn) {
|
|
1567
|
+
case "bandAmplitudeEnvelope":
|
|
1568
|
+
return "Amplitude Envelope";
|
|
1569
|
+
case "bandOnsetStrength":
|
|
1570
|
+
return "Onset Strength";
|
|
1571
|
+
case "bandSpectralFlux":
|
|
1572
|
+
return "Spectral Flux";
|
|
1573
|
+
case "bandSpectralCentroid":
|
|
1574
|
+
return "Spectral Centroid";
|
|
1575
|
+
default:
|
|
1576
|
+
return fn;
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
// src/dsp/bandEvents.ts
|
|
1581
|
+
function nowMs() {
|
|
1582
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1583
|
+
}
|
|
1584
|
+
function createMeta2(startMs) {
|
|
1585
|
+
const endMs = nowMs();
|
|
1586
|
+
const timings = {
|
|
1587
|
+
totalMs: endMs - startMs,
|
|
1588
|
+
cpuMs: endMs - startMs,
|
|
1589
|
+
gpuMs: 0
|
|
1590
|
+
};
|
|
1591
|
+
return {
|
|
1592
|
+
backend: "cpu",
|
|
1593
|
+
usedGpu: false,
|
|
1594
|
+
timings
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
function computeEventDiagnostics(events, durationSec) {
|
|
1598
|
+
const eventCount2 = events.length;
|
|
1599
|
+
const eventsPerSecond = durationSec > 0 ? eventCount2 / durationSec : 0;
|
|
1600
|
+
const warnings = [];
|
|
1601
|
+
if (durationSec > 1 && eventCount2 === 0) {
|
|
1602
|
+
warnings.push("No events detected - signal may be too quiet or noisy");
|
|
1603
|
+
} else if (eventsPerSecond > 20) {
|
|
1604
|
+
warnings.push("Very high event density (>20/sec) - consider adjusting threshold");
|
|
1605
|
+
} else if (durationSec > 10 && eventsPerSecond < 0.1) {
|
|
1606
|
+
warnings.push("Very sparse events (<0.1/sec) - signal may not be active");
|
|
1607
|
+
}
|
|
1608
|
+
return {
|
|
1609
|
+
eventCount: eventCount2,
|
|
1610
|
+
eventsPerSecond,
|
|
1611
|
+
warnings
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
function bandOnsetPeaks(signal, options) {
|
|
1615
|
+
const startMs = nowMs();
|
|
1616
|
+
const minIntervalSec = options?.minIntervalSec ?? 0.0625;
|
|
1617
|
+
const adaptiveFactor = options?.adaptiveFactor ?? 0.8;
|
|
1618
|
+
const strict = options?.strict ?? true;
|
|
1619
|
+
const { times, values } = signal;
|
|
1620
|
+
const pickOptions = {
|
|
1621
|
+
minIntervalSec,
|
|
1622
|
+
adaptive: {
|
|
1623
|
+
method: "meanStd",
|
|
1624
|
+
factor: adaptiveFactor
|
|
1625
|
+
},
|
|
1626
|
+
strict
|
|
1627
|
+
};
|
|
1628
|
+
const peaks = peakPick(times, values, pickOptions);
|
|
1629
|
+
const maxStrength = peaks.reduce((max, p) => Math.max(max, p.strength), 0);
|
|
1630
|
+
const events = peaks.map((p) => ({
|
|
1631
|
+
time: p.time,
|
|
1632
|
+
weight: maxStrength > 0 ? p.strength / maxStrength : 1
|
|
1633
|
+
}));
|
|
1634
|
+
const duration = times.length >= 2 ? (times[times.length - 1] ?? 0) - (times[0] ?? 0) : 0;
|
|
1635
|
+
return {
|
|
1636
|
+
kind: "bandEvents",
|
|
1637
|
+
bandId: signal.bandId,
|
|
1638
|
+
bandLabel: signal.bandLabel,
|
|
1639
|
+
fn: "bandOnsetPeaks",
|
|
1640
|
+
events,
|
|
1641
|
+
sourceSignal: {
|
|
1642
|
+
fn: signal.fn,
|
|
1643
|
+
times: signal.times,
|
|
1644
|
+
values: signal.values
|
|
1645
|
+
},
|
|
1646
|
+
meta: createMeta2(startMs),
|
|
1647
|
+
diagnostics: computeEventDiagnostics(events, duration)
|
|
1648
|
+
};
|
|
1649
|
+
}
|
|
1650
|
+
function bandBeatCandidates(onsetPeaks, options) {
|
|
1651
|
+
const startMs = nowMs();
|
|
1652
|
+
const minIntervalSec = options?.minIntervalSec ?? 0.1;
|
|
1653
|
+
const thresholdFactor = options?.thresholdFactor ?? 0.5;
|
|
1654
|
+
const weights = onsetPeaks.events.map((e) => e.weight);
|
|
1655
|
+
const meanWeight = weights.length > 0 ? weights.reduce((sum, w) => sum + w, 0) / weights.length : 0;
|
|
1656
|
+
const variance = weights.length > 0 ? weights.reduce((sum, w) => sum + (w - meanWeight) ** 2, 0) / weights.length : 0;
|
|
1657
|
+
const stdWeight = Math.sqrt(variance);
|
|
1658
|
+
const threshold = meanWeight + thresholdFactor * stdWeight;
|
|
1659
|
+
const candidates = [];
|
|
1660
|
+
let lastTime = -Infinity;
|
|
1661
|
+
for (const event of onsetPeaks.events) {
|
|
1662
|
+
if (event.weight < threshold) continue;
|
|
1663
|
+
if (event.time - lastTime < minIntervalSec) {
|
|
1664
|
+
const last = candidates[candidates.length - 1];
|
|
1665
|
+
if (last && event.weight > last.weight) {
|
|
1666
|
+
last.time = event.time;
|
|
1667
|
+
last.weight = event.weight;
|
|
1668
|
+
lastTime = event.time;
|
|
1669
|
+
}
|
|
1670
|
+
continue;
|
|
1671
|
+
}
|
|
1672
|
+
candidates.push({
|
|
1673
|
+
time: event.time,
|
|
1674
|
+
weight: event.weight,
|
|
1675
|
+
beatPosition: event.beatPosition,
|
|
1676
|
+
beatPhase: event.beatPhase
|
|
1677
|
+
});
|
|
1678
|
+
lastTime = event.time;
|
|
1679
|
+
}
|
|
1680
|
+
const duration = onsetPeaks.sourceSignal?.times ? onsetPeaks.sourceSignal.times.length >= 2 ? (onsetPeaks.sourceSignal.times[onsetPeaks.sourceSignal.times.length - 1] ?? 0) - (onsetPeaks.sourceSignal.times[0] ?? 0) : 0 : 0;
|
|
1681
|
+
return {
|
|
1682
|
+
kind: "bandEvents",
|
|
1683
|
+
bandId: onsetPeaks.bandId,
|
|
1684
|
+
bandLabel: onsetPeaks.bandLabel,
|
|
1685
|
+
fn: "bandBeatCandidates",
|
|
1686
|
+
events: candidates,
|
|
1687
|
+
// Don't include source signal to save memory
|
|
1688
|
+
meta: createMeta2(startMs),
|
|
1689
|
+
diagnostics: computeEventDiagnostics(candidates, duration)
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
async function runBandEventsBatch(request) {
|
|
1693
|
+
const startMs = nowMs();
|
|
1694
|
+
const results = /* @__PURE__ */ new Map();
|
|
1695
|
+
const sourceFunction = request.sourceFunction ?? "bandOnsetStrength";
|
|
1696
|
+
for (const [bandId, mirResults] of request.bandMirResults.entries()) {
|
|
1697
|
+
const bandEventResults = [];
|
|
1698
|
+
const sourceSignal = mirResults.find((r) => r.fn === sourceFunction);
|
|
1699
|
+
if (!sourceSignal) {
|
|
1700
|
+
continue;
|
|
1701
|
+
}
|
|
1702
|
+
for (const fn of request.functions) {
|
|
1703
|
+
switch (fn) {
|
|
1704
|
+
case "bandOnsetPeaks": {
|
|
1705
|
+
const result = bandOnsetPeaks(sourceSignal, request.onsetPeaksOptions);
|
|
1706
|
+
bandEventResults.push(result);
|
|
1707
|
+
break;
|
|
1708
|
+
}
|
|
1709
|
+
case "bandBeatCandidates": {
|
|
1710
|
+
let onsetPeaksResult = bandEventResults.find(
|
|
1711
|
+
(r) => r.fn === "bandOnsetPeaks"
|
|
1712
|
+
);
|
|
1713
|
+
if (!onsetPeaksResult) {
|
|
1714
|
+
onsetPeaksResult = bandOnsetPeaks(
|
|
1715
|
+
sourceSignal,
|
|
1716
|
+
request.onsetPeaksOptions
|
|
1717
|
+
);
|
|
1718
|
+
if (!request.functions.includes("bandOnsetPeaks")) ; else {
|
|
1719
|
+
bandEventResults.push(onsetPeaksResult);
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
const result = bandBeatCandidates(
|
|
1723
|
+
onsetPeaksResult,
|
|
1724
|
+
request.beatCandidatesOptions
|
|
1725
|
+
);
|
|
1726
|
+
bandEventResults.push(result);
|
|
1727
|
+
break;
|
|
1728
|
+
}
|
|
1729
|
+
default: {
|
|
1730
|
+
const _exhaustive = fn;
|
|
1731
|
+
throw new Error(`Unknown band event function: ${_exhaustive}`);
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (bandEventResults.length > 0) {
|
|
1736
|
+
results.set(bandId, bandEventResults);
|
|
1737
|
+
}
|
|
1738
|
+
}
|
|
1739
|
+
const endMs = nowMs();
|
|
1740
|
+
return {
|
|
1741
|
+
results,
|
|
1742
|
+
totalTimingMs: endMs - startMs
|
|
1743
|
+
};
|
|
1744
|
+
}
|
|
1745
|
+
function getBandEventFunctionLabel(fn) {
|
|
1746
|
+
switch (fn) {
|
|
1747
|
+
case "bandOnsetPeaks":
|
|
1748
|
+
return "Onset Peaks";
|
|
1749
|
+
case "bandBeatCandidates":
|
|
1750
|
+
return "Beat Candidates";
|
|
1751
|
+
default:
|
|
1752
|
+
return fn;
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// src/dsp/bandCqt.ts
|
|
1757
|
+
function nowMs2() {
|
|
1758
|
+
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
1759
|
+
}
|
|
1760
|
+
function createMeta3(startMs) {
|
|
1761
|
+
const endMs = nowMs2();
|
|
1762
|
+
const timings = {
|
|
1763
|
+
totalMs: endMs - startMs,
|
|
1764
|
+
cpuMs: endMs - startMs,
|
|
1765
|
+
gpuMs: 0
|
|
1766
|
+
};
|
|
1767
|
+
return {
|
|
1768
|
+
backend: "cpu",
|
|
1769
|
+
usedGpu: false,
|
|
1770
|
+
timings
|
|
1771
|
+
};
|
|
1772
|
+
}
|
|
1773
|
+
function computeDiagnostics2(energyRetainedPerFrame) {
|
|
1774
|
+
const totalFrames = energyRetainedPerFrame.length;
|
|
1775
|
+
let sum = 0;
|
|
1776
|
+
let weakCount = 0;
|
|
1777
|
+
let emptyCount = 0;
|
|
1778
|
+
for (let i = 0; i < totalFrames; i++) {
|
|
1779
|
+
const e = energyRetainedPerFrame[i] ?? 0;
|
|
1780
|
+
sum += e;
|
|
1781
|
+
if (e < 0.01) weakCount++;
|
|
1782
|
+
if (e === 0) emptyCount++;
|
|
1783
|
+
}
|
|
1784
|
+
const meanEnergyRetained = totalFrames > 0 ? sum / totalFrames : 0;
|
|
1785
|
+
const warnings = [];
|
|
1786
|
+
if (meanEnergyRetained < 0.01) {
|
|
1787
|
+
warnings.push("Band contains very little CQT energy - check frequency range");
|
|
1788
|
+
}
|
|
1789
|
+
if (emptyCount > totalFrames * 0.5) {
|
|
1790
|
+
warnings.push("More than half of frames are empty in CQT - band may not be active");
|
|
1791
|
+
}
|
|
1792
|
+
return {
|
|
1793
|
+
meanEnergyRetained,
|
|
1794
|
+
weakFrameCount: weakCount,
|
|
1795
|
+
emptyFrameCount: emptyCount,
|
|
1796
|
+
totalFrames,
|
|
1797
|
+
warnings
|
|
1798
|
+
};
|
|
1799
|
+
}
|
|
1800
|
+
function getBandBoundsAtTime(band, timeSec) {
|
|
1801
|
+
for (const seg of band.frequencyShape) {
|
|
1802
|
+
if (timeSec >= seg.startTime && timeSec < seg.endTime) {
|
|
1803
|
+
const ratio = (timeSec - seg.startTime) / (seg.endTime - seg.startTime);
|
|
1804
|
+
const lowHz = seg.lowHzStart + ratio * (seg.lowHzEnd - seg.lowHzStart);
|
|
1805
|
+
const highHz = seg.highHzStart + ratio * (seg.highHzEnd - seg.highHzStart);
|
|
1806
|
+
return { lowHz, highHz };
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
const first = band.frequencyShape[0];
|
|
1810
|
+
if (first) {
|
|
1811
|
+
return { lowHz: first.lowHzStart, highHz: first.highHzStart };
|
|
1812
|
+
}
|
|
1813
|
+
return { lowHz: 20, highHz: 2e4 };
|
|
1814
|
+
}
|
|
1815
|
+
function applyBandMaskToCqt(cqt, band, options) {
|
|
1816
|
+
const edgeSmoothBins = options?.edgeSmoothBins ?? 0;
|
|
1817
|
+
const nFrames = cqt.times.length;
|
|
1818
|
+
const nBins = cqt.binFrequencies.length;
|
|
1819
|
+
const maskedMagnitudes = new Array(nFrames);
|
|
1820
|
+
const energyRetainedPerFrame = new Float32Array(nFrames);
|
|
1821
|
+
for (let t = 0; t < nFrames; t++) {
|
|
1822
|
+
const frame = cqt.magnitudes[t];
|
|
1823
|
+
if (!frame) {
|
|
1824
|
+
maskedMagnitudes[t] = new Float32Array(nBins);
|
|
1825
|
+
energyRetainedPerFrame[t] = 0;
|
|
1826
|
+
continue;
|
|
1827
|
+
}
|
|
1828
|
+
const timeSec = cqt.times[t] ?? 0;
|
|
1829
|
+
const { lowHz, highHz } = getBandBoundsAtTime(band, timeSec);
|
|
1830
|
+
const lowBin = Math.max(0, Math.floor(hzToCqtBin(lowHz, cqt.config)));
|
|
1831
|
+
const highBin = Math.min(nBins, Math.ceil(hzToCqtBin(highHz, cqt.config)));
|
|
1832
|
+
const maskedFrame = new Float32Array(nBins);
|
|
1833
|
+
let originalEnergy = 0;
|
|
1834
|
+
let retainedEnergy = 0;
|
|
1835
|
+
for (let k = 0; k < nBins; k++) {
|
|
1836
|
+
const mag = frame[k] ?? 0;
|
|
1837
|
+
originalEnergy += mag * mag;
|
|
1838
|
+
if (k >= lowBin && k < highBin) {
|
|
1839
|
+
let weight = 1;
|
|
1840
|
+
if (edgeSmoothBins > 0) {
|
|
1841
|
+
const distFromLow = k - lowBin;
|
|
1842
|
+
const distFromHigh = highBin - 1 - k;
|
|
1843
|
+
const minDist = Math.min(distFromLow, distFromHigh);
|
|
1844
|
+
if (minDist < edgeSmoothBins) {
|
|
1845
|
+
weight = (minDist + 1) / (edgeSmoothBins + 1);
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
maskedFrame[k] = mag * weight;
|
|
1849
|
+
retainedEnergy += (mag * weight) ** 2;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
maskedMagnitudes[t] = maskedFrame;
|
|
1853
|
+
energyRetainedPerFrame[t] = originalEnergy > 0 ? Math.sqrt(retainedEnergy / originalEnergy) : 0;
|
|
1854
|
+
}
|
|
1855
|
+
return {
|
|
1856
|
+
times: cqt.times,
|
|
1857
|
+
magnitudes: maskedMagnitudes,
|
|
1858
|
+
config: cqt.config,
|
|
1859
|
+
energyRetainedPerFrame,
|
|
1860
|
+
binFrequencies: cqt.binFrequencies
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
function bandCqtHarmonicEnergy(cqt, band, options) {
|
|
1864
|
+
const startMs = nowMs2();
|
|
1865
|
+
const masked = applyBandMaskToCqt(cqt, band, options);
|
|
1866
|
+
const nFrames = masked.times.length;
|
|
1867
|
+
const values = new Float32Array(nFrames);
|
|
1868
|
+
for (let t = 0; t < nFrames; t++) {
|
|
1869
|
+
if (options?.isCancelled?.()) {
|
|
1870
|
+
throw new Error("@octoseq/mir: cancelled");
|
|
1871
|
+
}
|
|
1872
|
+
const frame = masked.magnitudes[t];
|
|
1873
|
+
if (!frame) {
|
|
1874
|
+
values[t] = 0;
|
|
1875
|
+
continue;
|
|
1876
|
+
}
|
|
1877
|
+
let totalEnergy = 0;
|
|
1878
|
+
for (let k = 0; k < frame.length; k++) {
|
|
1879
|
+
const mag = frame[k] ?? 0;
|
|
1880
|
+
totalEnergy += mag * mag;
|
|
1881
|
+
}
|
|
1882
|
+
if (totalEnergy === 0) {
|
|
1883
|
+
values[t] = 0;
|
|
1884
|
+
continue;
|
|
1885
|
+
}
|
|
1886
|
+
let maxMag = 0;
|
|
1887
|
+
let fundamentalBin = 0;
|
|
1888
|
+
for (let k = 0; k < frame.length; k++) {
|
|
1889
|
+
const mag = frame[k] ?? 0;
|
|
1890
|
+
if (mag > maxMag) {
|
|
1891
|
+
maxMag = mag;
|
|
1892
|
+
fundamentalBin = k;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
const fundamentalFreq = cqtBinToHz(fundamentalBin, cqt.config);
|
|
1896
|
+
let harmonicEnergy2 = 0;
|
|
1897
|
+
const numHarmonics = 6;
|
|
1898
|
+
for (let h = 1; h <= numHarmonics; h++) {
|
|
1899
|
+
const harmonicFreq = fundamentalFreq * h;
|
|
1900
|
+
const harmonicBin = Math.round(hzToCqtBin(harmonicFreq, cqt.config));
|
|
1901
|
+
if (harmonicBin >= 0 && harmonicBin < frame.length) {
|
|
1902
|
+
const mag = frame[harmonicBin] ?? 0;
|
|
1903
|
+
const weight = 1 / h;
|
|
1904
|
+
harmonicEnergy2 += mag * mag * weight;
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
let weightSum = 0;
|
|
1908
|
+
for (let h = 1; h <= numHarmonics; h++) {
|
|
1909
|
+
weightSum += 1 / h;
|
|
1910
|
+
}
|
|
1911
|
+
harmonicEnergy2 /= weightSum;
|
|
1912
|
+
values[t] = Math.min(1, harmonicEnergy2 / totalEnergy);
|
|
1913
|
+
}
|
|
1914
|
+
const normalized = normalizeMinMax(values);
|
|
1915
|
+
return {
|
|
1916
|
+
kind: "bandCqt1d",
|
|
1917
|
+
bandId: band.id,
|
|
1918
|
+
bandLabel: band.label,
|
|
1919
|
+
fn: "bandCqtHarmonicEnergy",
|
|
1920
|
+
times: masked.times,
|
|
1921
|
+
values: normalized,
|
|
1922
|
+
meta: createMeta3(startMs),
|
|
1923
|
+
diagnostics: computeDiagnostics2(masked.energyRetainedPerFrame)
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
function bandCqtBassPitchMotion(cqt, band, options) {
|
|
1927
|
+
const startMs = nowMs2();
|
|
1928
|
+
const masked = applyBandMaskToCqt(cqt, band, options);
|
|
1929
|
+
const nFrames = masked.times.length;
|
|
1930
|
+
const BASS_MIN_HZ = 20;
|
|
1931
|
+
const BASS_MAX_HZ = 300;
|
|
1932
|
+
const bassStartBin = Math.max(0, Math.floor(hzToCqtBin(BASS_MIN_HZ, cqt.config)));
|
|
1933
|
+
const bassEndBin = Math.min(
|
|
1934
|
+
masked.binFrequencies.length,
|
|
1935
|
+
Math.ceil(hzToCqtBin(BASS_MAX_HZ, cqt.config))
|
|
1936
|
+
);
|
|
1937
|
+
const bassNumBins = bassEndBin - bassStartBin;
|
|
1938
|
+
if (bassNumBins <= 0) {
|
|
1939
|
+
return {
|
|
1940
|
+
kind: "bandCqt1d",
|
|
1941
|
+
bandId: band.id,
|
|
1942
|
+
bandLabel: band.label,
|
|
1943
|
+
fn: "bandCqtBassPitchMotion",
|
|
1944
|
+
times: masked.times,
|
|
1945
|
+
values: new Float32Array(nFrames),
|
|
1946
|
+
meta: createMeta3(startMs),
|
|
1947
|
+
diagnostics: {
|
|
1948
|
+
...computeDiagnostics2(masked.energyRetainedPerFrame),
|
|
1949
|
+
warnings: ["Band does not overlap bass frequency range (20-300 Hz)"]
|
|
1950
|
+
}
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
const centroids = new Float32Array(nFrames);
|
|
1954
|
+
for (let t = 0; t < nFrames; t++) {
|
|
1955
|
+
const frame = masked.magnitudes[t];
|
|
1956
|
+
if (!frame) continue;
|
|
1957
|
+
let num = 0;
|
|
1958
|
+
let den = 0;
|
|
1959
|
+
for (let k = bassStartBin; k < bassEndBin; k++) {
|
|
1960
|
+
const mag = frame[k] ?? 0;
|
|
1961
|
+
if (mag > 0) {
|
|
1962
|
+
num += k * mag;
|
|
1963
|
+
den += mag;
|
|
1964
|
+
}
|
|
1965
|
+
}
|
|
1966
|
+
centroids[t] = den > 0 ? num / den : bassStartBin + bassNumBins / 2;
|
|
1967
|
+
}
|
|
1968
|
+
const motion = new Float32Array(nFrames);
|
|
1969
|
+
for (let t = 1; t < nFrames; t++) {
|
|
1970
|
+
motion[t] = Math.abs((centroids[t] ?? 0) - (centroids[t - 1] ?? 0));
|
|
1971
|
+
}
|
|
1972
|
+
if (nFrames > 1) {
|
|
1973
|
+
motion[0] = motion[1] ?? 0;
|
|
1974
|
+
}
|
|
1975
|
+
const normalized = normalizeMinMax(motion);
|
|
1976
|
+
return {
|
|
1977
|
+
kind: "bandCqt1d",
|
|
1978
|
+
bandId: band.id,
|
|
1979
|
+
bandLabel: band.label,
|
|
1980
|
+
fn: "bandCqtBassPitchMotion",
|
|
1981
|
+
times: masked.times,
|
|
1982
|
+
values: normalized,
|
|
1983
|
+
meta: createMeta3(startMs),
|
|
1984
|
+
diagnostics: computeDiagnostics2(masked.energyRetainedPerFrame)
|
|
1985
|
+
};
|
|
1986
|
+
}
|
|
1987
|
+
function bandCqtTonalStability(cqt, band, options) {
|
|
1988
|
+
const startMs = nowMs2();
|
|
1989
|
+
const masked = applyBandMaskToCqt(cqt, band, options);
|
|
1990
|
+
const nFrames = masked.times.length;
|
|
1991
|
+
const CHROMA_BINS = 12;
|
|
1992
|
+
const WINDOW_FRAMES = 20;
|
|
1993
|
+
const chromas = new Array(nFrames);
|
|
1994
|
+
for (let t = 0; t < nFrames; t++) {
|
|
1995
|
+
const frame = masked.magnitudes[t];
|
|
1996
|
+
const chroma = new Float32Array(CHROMA_BINS);
|
|
1997
|
+
if (frame) {
|
|
1998
|
+
const binsPerSemitone = cqt.binsPerOctave / CHROMA_BINS;
|
|
1999
|
+
for (let k = 0; k < frame.length; k++) {
|
|
2000
|
+
const chromaBin = Math.floor(k % cqt.binsPerOctave / binsPerSemitone) % CHROMA_BINS;
|
|
2001
|
+
const mag = frame[k] ?? 0;
|
|
2002
|
+
chroma[chromaBin] = (chroma[chromaBin] ?? 0) + mag * mag;
|
|
2003
|
+
}
|
|
2004
|
+
let sum = 0;
|
|
2005
|
+
for (let c = 0; c < CHROMA_BINS; c++) {
|
|
2006
|
+
sum += chroma[c] ?? 0;
|
|
2007
|
+
}
|
|
2008
|
+
if (sum > 0) {
|
|
2009
|
+
for (let c = 0; c < CHROMA_BINS; c++) {
|
|
2010
|
+
chroma[c] = (chroma[c] ?? 0) / sum;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
chromas[t] = chroma;
|
|
2015
|
+
}
|
|
2016
|
+
const halfWindow = Math.floor(WINDOW_FRAMES / 2);
|
|
2017
|
+
const instability = new Float32Array(nFrames);
|
|
2018
|
+
for (let t = 0; t < nFrames; t++) {
|
|
2019
|
+
const windowStart = Math.max(0, t - halfWindow);
|
|
2020
|
+
const windowEnd = Math.min(nFrames, t + halfWindow + 1);
|
|
2021
|
+
const windowSize = windowEnd - windowStart;
|
|
2022
|
+
const avgChroma = new Float32Array(CHROMA_BINS);
|
|
2023
|
+
for (let w = windowStart; w < windowEnd; w++) {
|
|
2024
|
+
const chroma = chromas[w];
|
|
2025
|
+
if (chroma) {
|
|
2026
|
+
for (let c = 0; c < CHROMA_BINS; c++) {
|
|
2027
|
+
avgChroma[c] = (avgChroma[c] ?? 0) + (chroma[c] ?? 0);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
for (let c = 0; c < CHROMA_BINS; c++) {
|
|
2032
|
+
avgChroma[c] = (avgChroma[c] ?? 0) / windowSize;
|
|
2033
|
+
}
|
|
2034
|
+
let totalVariance = 0;
|
|
2035
|
+
for (let w = windowStart; w < windowEnd; w++) {
|
|
2036
|
+
const chroma = chromas[w];
|
|
2037
|
+
if (chroma) {
|
|
2038
|
+
for (let c = 0; c < CHROMA_BINS; c++) {
|
|
2039
|
+
const diff = (chroma[c] ?? 0) - (avgChroma[c] ?? 0);
|
|
2040
|
+
totalVariance += diff * diff;
|
|
2041
|
+
}
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
totalVariance /= windowSize * CHROMA_BINS;
|
|
2045
|
+
instability[t] = totalVariance;
|
|
2046
|
+
}
|
|
2047
|
+
const normalizedInstability = normalizeMinMax(instability);
|
|
2048
|
+
const stability = new Float32Array(nFrames);
|
|
2049
|
+
for (let t = 0; t < nFrames; t++) {
|
|
2050
|
+
stability[t] = 1 - (normalizedInstability[t] ?? 0);
|
|
2051
|
+
}
|
|
2052
|
+
return {
|
|
2053
|
+
kind: "bandCqt1d",
|
|
2054
|
+
bandId: band.id,
|
|
2055
|
+
bandLabel: band.label,
|
|
2056
|
+
fn: "bandCqtTonalStability",
|
|
2057
|
+
times: masked.times,
|
|
2058
|
+
values: stability,
|
|
2059
|
+
meta: createMeta3(startMs),
|
|
2060
|
+
diagnostics: computeDiagnostics2(masked.energyRetainedPerFrame)
|
|
2061
|
+
};
|
|
2062
|
+
}
|
|
2063
|
+
function normalizeMinMax(values) {
|
|
2064
|
+
const n = values.length;
|
|
2065
|
+
if (n === 0) return new Float32Array(0);
|
|
2066
|
+
let min = Infinity;
|
|
2067
|
+
let max = -Infinity;
|
|
2068
|
+
for (let i = 0; i < n; i++) {
|
|
2069
|
+
const v = values[i] ?? 0;
|
|
2070
|
+
if (v < min) min = v;
|
|
2071
|
+
if (v > max) max = v;
|
|
2072
|
+
}
|
|
2073
|
+
const out = new Float32Array(n);
|
|
2074
|
+
const range = max - min;
|
|
2075
|
+
if (range === 0 || !Number.isFinite(range)) {
|
|
2076
|
+
out.fill(0.5);
|
|
2077
|
+
return out;
|
|
2078
|
+
}
|
|
2079
|
+
for (let i = 0; i < n; i++) {
|
|
2080
|
+
out[i] = ((values[i] ?? 0) - min) / range;
|
|
2081
|
+
}
|
|
2082
|
+
return out;
|
|
2083
|
+
}
|
|
2084
|
+
async function runBandCqtBatch(cqt, request, options) {
|
|
2085
|
+
const startMs = nowMs2();
|
|
2086
|
+
const results = /* @__PURE__ */ new Map();
|
|
2087
|
+
for (const band of request.bands) {
|
|
2088
|
+
if (options?.isCancelled?.()) {
|
|
2089
|
+
throw new Error("@octoseq/mir: cancelled");
|
|
2090
|
+
}
|
|
2091
|
+
if (!band.enabled) continue;
|
|
2092
|
+
const bandResults = [];
|
|
2093
|
+
for (const fn of request.functions) {
|
|
2094
|
+
if (options?.isCancelled?.()) {
|
|
2095
|
+
throw new Error("@octoseq/mir: cancelled");
|
|
2096
|
+
}
|
|
2097
|
+
let result;
|
|
2098
|
+
switch (fn) {
|
|
2099
|
+
case "bandCqtHarmonicEnergy":
|
|
2100
|
+
result = bandCqtHarmonicEnergy(cqt, band, options);
|
|
2101
|
+
break;
|
|
2102
|
+
case "bandCqtBassPitchMotion":
|
|
2103
|
+
result = bandCqtBassPitchMotion(cqt, band, options);
|
|
2104
|
+
break;
|
|
2105
|
+
case "bandCqtTonalStability":
|
|
2106
|
+
result = bandCqtTonalStability(cqt, band, options);
|
|
2107
|
+
break;
|
|
2108
|
+
default:
|
|
2109
|
+
const _exhaustive = fn;
|
|
2110
|
+
throw new Error(`Unknown band CQT function: ${_exhaustive}`);
|
|
2111
|
+
}
|
|
2112
|
+
bandResults.push(result);
|
|
2113
|
+
}
|
|
2114
|
+
results.set(band.id, bandResults);
|
|
2115
|
+
}
|
|
2116
|
+
const endMs = nowMs2();
|
|
2117
|
+
return {
|
|
2118
|
+
results,
|
|
2119
|
+
totalTimingMs: endMs - startMs
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
function getBandCqtFunctionLabel(fn) {
|
|
2123
|
+
switch (fn) {
|
|
2124
|
+
case "bandCqtHarmonicEnergy":
|
|
2125
|
+
return "Harmonic Energy (CQT)";
|
|
2126
|
+
case "bandCqtBassPitchMotion":
|
|
2127
|
+
return "Bass Pitch Motion (CQT)";
|
|
2128
|
+
case "bandCqtTonalStability":
|
|
2129
|
+
return "Tonal Stability (CQT)";
|
|
2130
|
+
default:
|
|
2131
|
+
return fn;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
// src/dsp/bandProposal.ts
|
|
2136
|
+
var PROPOSAL_DEFAULTS = {
|
|
2137
|
+
maxProposals: 8,
|
|
2138
|
+
minSalience: 0.3,
|
|
2139
|
+
minSeparationOctaves: 0.5,
|
|
2140
|
+
minBandwidthHz: 20,
|
|
2141
|
+
analysisWindow: 0
|
|
2142
|
+
// 0 = full track
|
|
2143
|
+
};
|
|
2144
|
+
function generateProposalId() {
|
|
2145
|
+
return `proposal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2146
|
+
}
|
|
2147
|
+
function generateBandId2() {
|
|
2148
|
+
return `band-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2149
|
+
}
|
|
2150
|
+
function computeAverageCqtSpectrum(cqt) {
|
|
2151
|
+
const nBins = cqt.magnitudes[0]?.length ?? 0;
|
|
2152
|
+
const nFrames = cqt.magnitudes.length;
|
|
2153
|
+
const average = new Float32Array(nBins);
|
|
2154
|
+
for (let frame = 0; frame < nFrames; frame++) {
|
|
2155
|
+
const frameMags = cqt.magnitudes[frame];
|
|
2156
|
+
if (!frameMags) continue;
|
|
2157
|
+
for (let bin = 0; bin < nBins; bin++) {
|
|
2158
|
+
average[bin] = (average[bin] ?? 0) + (frameMags[bin] ?? 0);
|
|
2159
|
+
}
|
|
2160
|
+
}
|
|
2161
|
+
for (let bin = 0; bin < nBins; bin++) {
|
|
2162
|
+
average[bin] = (average[bin] ?? 0) / nFrames;
|
|
2163
|
+
}
|
|
2164
|
+
return average;
|
|
2165
|
+
}
|
|
2166
|
+
function findLocalMaxima(values, minNeighborDistance = 3) {
|
|
2167
|
+
const peaks = [];
|
|
2168
|
+
for (let i = minNeighborDistance; i < values.length - minNeighborDistance; i++) {
|
|
2169
|
+
let isPeak = true;
|
|
2170
|
+
const centerVal = values[i] ?? 0;
|
|
2171
|
+
for (let j = -minNeighborDistance; j <= minNeighborDistance; j++) {
|
|
2172
|
+
if (j === 0) continue;
|
|
2173
|
+
if ((values[i + j] ?? 0) >= centerVal) {
|
|
2174
|
+
isPeak = false;
|
|
2175
|
+
break;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
if (isPeak && centerVal > 0) {
|
|
2179
|
+
peaks.push(i);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
return peaks;
|
|
2183
|
+
}
|
|
2184
|
+
function computeBandwidth(values, peakBin, cqt, minBandwidthHz) {
|
|
2185
|
+
const peakMag = values[peakBin] ?? 0;
|
|
2186
|
+
const threshold = peakMag * 0.707;
|
|
2187
|
+
let lowBin = peakBin;
|
|
2188
|
+
while (lowBin > 0 && (values[lowBin - 1] ?? 0) >= threshold) {
|
|
2189
|
+
lowBin--;
|
|
2190
|
+
}
|
|
2191
|
+
let highBin = peakBin;
|
|
2192
|
+
while (highBin < values.length - 1 && (values[highBin + 1] ?? 0) >= threshold) {
|
|
2193
|
+
highBin++;
|
|
2194
|
+
}
|
|
2195
|
+
if (minBandwidthHz > 0) {
|
|
2196
|
+
const maxExpansions = values.length;
|
|
2197
|
+
for (let i = 0; i < maxExpansions; i++) {
|
|
2198
|
+
const lowHzTmp = cqtBinToHz(lowBin, cqt.config);
|
|
2199
|
+
const highHzTmp = cqtBinToHz(highBin, cqt.config);
|
|
2200
|
+
if (highHzTmp - lowHzTmp >= minBandwidthHz) break;
|
|
2201
|
+
const canExpandLow = lowBin > 0;
|
|
2202
|
+
const canExpandHigh = highBin < values.length - 1;
|
|
2203
|
+
if (!canExpandLow && !canExpandHigh) break;
|
|
2204
|
+
if (canExpandLow && canExpandHigh) {
|
|
2205
|
+
lowBin--;
|
|
2206
|
+
highBin++;
|
|
2207
|
+
} else if (canExpandLow) {
|
|
2208
|
+
lowBin--;
|
|
2209
|
+
} else {
|
|
2210
|
+
highBin++;
|
|
2211
|
+
}
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
const lowHz = cqtBinToHz(lowBin, cqt.config);
|
|
2215
|
+
const highHz = cqtBinToHz(highBin, cqt.config);
|
|
2216
|
+
const bandwidthOctaves = Math.log2(highHz / lowHz);
|
|
2217
|
+
return { lowBin, highBin, lowHz, highHz, bandwidthOctaves };
|
|
2218
|
+
}
|
|
2219
|
+
function computeTemporalVariance(cqt, lowBin, highBin) {
|
|
2220
|
+
const nFrames = cqt.magnitudes.length;
|
|
2221
|
+
const bandEnergies = new Float32Array(nFrames);
|
|
2222
|
+
for (let frame = 0; frame < nFrames; frame++) {
|
|
2223
|
+
const frameMags = cqt.magnitudes[frame];
|
|
2224
|
+
if (!frameMags) continue;
|
|
2225
|
+
let energy = 0;
|
|
2226
|
+
for (let bin = lowBin; bin <= highBin; bin++) {
|
|
2227
|
+
const mag = frameMags[bin] ?? 0;
|
|
2228
|
+
energy += mag * mag;
|
|
2229
|
+
}
|
|
2230
|
+
bandEnergies[frame] = energy;
|
|
2231
|
+
}
|
|
2232
|
+
let sum = 0;
|
|
2233
|
+
for (let i = 0; i < nFrames; i++) {
|
|
2234
|
+
sum += bandEnergies[i] ?? 0;
|
|
2235
|
+
}
|
|
2236
|
+
const mean = sum / nFrames;
|
|
2237
|
+
let variance = 0;
|
|
2238
|
+
for (let i = 0; i < nFrames; i++) {
|
|
2239
|
+
const diff = (bandEnergies[i] ?? 0) - mean;
|
|
2240
|
+
variance += diff * diff;
|
|
2241
|
+
}
|
|
2242
|
+
variance /= nFrames;
|
|
2243
|
+
return mean > 0 ? variance / (mean * mean) : 0;
|
|
2244
|
+
}
|
|
2245
|
+
function detectSpectralPeaks(cqt, config) {
|
|
2246
|
+
const avgSpectrum = computeAverageCqtSpectrum(cqt);
|
|
2247
|
+
const minBinDistance = Math.ceil(config.minSeparationOctaves * cqt.binsPerOctave);
|
|
2248
|
+
const peakIndices = findLocalMaxima(avgSpectrum, Math.max(3, minBinDistance / 2));
|
|
2249
|
+
const peaks = [];
|
|
2250
|
+
for (const binIndex of peakIndices) {
|
|
2251
|
+
const bw = computeBandwidth(avgSpectrum, binIndex, cqt, config.minBandwidthHz);
|
|
2252
|
+
peaks.push({
|
|
2253
|
+
binIndex,
|
|
2254
|
+
centerHz: cqtBinToHz(binIndex, cqt.config),
|
|
2255
|
+
magnitude: avgSpectrum[binIndex] ?? 0,
|
|
2256
|
+
bandwidth: bw.bandwidthOctaves,
|
|
2257
|
+
lowHz: bw.lowHz,
|
|
2258
|
+
highHz: bw.highHz
|
|
2259
|
+
});
|
|
2260
|
+
}
|
|
2261
|
+
peaks.sort((a, b) => b.magnitude - a.magnitude);
|
|
2262
|
+
return peaks;
|
|
2263
|
+
}
|
|
2264
|
+
function scorePeak(peak, cqt, avgSpectrum, cqtSignals) {
|
|
2265
|
+
const lowBin = Math.max(0, Math.floor(peak.lowHz / (cqt.config.fMin * Math.pow(2, 1 / cqt.binsPerOctave))));
|
|
2266
|
+
const highBin = Math.min(
|
|
2267
|
+
getNumBins(cqt.config) - 1,
|
|
2268
|
+
Math.ceil(peak.highHz / (cqt.config.fMin * Math.pow(2, 1 / cqt.binsPerOctave)))
|
|
2269
|
+
);
|
|
2270
|
+
const temporalVariance = computeTemporalVariance(cqt, lowBin, highBin);
|
|
2271
|
+
let bandEnergy = 0;
|
|
2272
|
+
let totalEnergy = 0;
|
|
2273
|
+
for (let bin = 0; bin < avgSpectrum.length; bin++) {
|
|
2274
|
+
const mag = avgSpectrum[bin] ?? 0;
|
|
2275
|
+
totalEnergy += mag * mag;
|
|
2276
|
+
if (bin >= lowBin && bin <= highBin) {
|
|
2277
|
+
bandEnergy += mag * mag;
|
|
2278
|
+
}
|
|
2279
|
+
}
|
|
2280
|
+
const energyConcentration = totalEnergy > 0 ? bandEnergy / totalEnergy : 0;
|
|
2281
|
+
const isBassRange = peak.centerHz < 300;
|
|
2282
|
+
const isLowMidRange = peak.centerHz >= 300 && peak.centerHz < 1e3;
|
|
2283
|
+
let avgHarmonicEnergy = 0;
|
|
2284
|
+
let avgBassPitchMotion = 0;
|
|
2285
|
+
let avgTonalStability = 0;
|
|
2286
|
+
const nFrames = cqtSignals.harmonicEnergy.length;
|
|
2287
|
+
for (let i = 0; i < nFrames; i++) {
|
|
2288
|
+
avgHarmonicEnergy += cqtSignals.harmonicEnergy[i] ?? 0;
|
|
2289
|
+
avgBassPitchMotion += cqtSignals.bassPitchMotion[i] ?? 0;
|
|
2290
|
+
avgTonalStability += cqtSignals.tonalStability[i] ?? 0;
|
|
2291
|
+
}
|
|
2292
|
+
avgHarmonicEnergy /= nFrames;
|
|
2293
|
+
avgBassPitchMotion /= nFrames;
|
|
2294
|
+
avgTonalStability /= nFrames;
|
|
2295
|
+
let source;
|
|
2296
|
+
let reason;
|
|
2297
|
+
let salience;
|
|
2298
|
+
if (isBassRange && avgBassPitchMotion > 0.4) {
|
|
2299
|
+
source = "cqt_bass_motion";
|
|
2300
|
+
reason = "Significant bass pitch motion detected";
|
|
2301
|
+
salience = 0.3 + energyConcentration * 0.3 + avgBassPitchMotion * 0.4;
|
|
2302
|
+
} else if (avgHarmonicEnergy > 0.5 && avgTonalStability > 0.5) {
|
|
2303
|
+
source = "cqt_harmonic";
|
|
2304
|
+
reason = "Strong harmonic structure with stable tonality";
|
|
2305
|
+
salience = 0.3 + avgHarmonicEnergy * 0.35 + avgTonalStability * 0.35;
|
|
2306
|
+
} else if (temporalVariance > 0.5) {
|
|
2307
|
+
source = "onset_band";
|
|
2308
|
+
reason = "Distinct transient activity pattern";
|
|
2309
|
+
salience = 0.3 + energyConcentration * 0.3 + Math.min(1, temporalVariance) * 0.4;
|
|
2310
|
+
} else if (energyConcentration > 0.1) {
|
|
2311
|
+
source = "energy_cluster";
|
|
2312
|
+
reason = "Concentrated spectral energy";
|
|
2313
|
+
salience = 0.2 + energyConcentration * 0.5 + (1 - Math.min(1, peak.bandwidth)) * 0.3;
|
|
2314
|
+
} else {
|
|
2315
|
+
source = "spectral_peak";
|
|
2316
|
+
reason = "Persistent spectral peak";
|
|
2317
|
+
salience = 0.2 + energyConcentration * 0.4 + (1 - Math.min(1, peak.bandwidth * 2)) * 0.4;
|
|
2318
|
+
}
|
|
2319
|
+
if (isBassRange) {
|
|
2320
|
+
reason += " (bass region)";
|
|
2321
|
+
} else if (isLowMidRange) {
|
|
2322
|
+
reason += " (low-mid region)";
|
|
2323
|
+
} else if (peak.centerHz > 4e3) {
|
|
2324
|
+
reason += " (high frequency region)";
|
|
2325
|
+
}
|
|
2326
|
+
return {
|
|
2327
|
+
peak,
|
|
2328
|
+
salience: Math.min(1, Math.max(0, salience)),
|
|
2329
|
+
source,
|
|
2330
|
+
reason
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
function createBandFromCandidate(candidate, duration) {
|
|
2334
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
2335
|
+
const provenance = {
|
|
2336
|
+
source: "manual",
|
|
2337
|
+
// Will be treated as imported when promoted
|
|
2338
|
+
createdAt: now
|
|
2339
|
+
};
|
|
2340
|
+
return {
|
|
2341
|
+
id: generateBandId2(),
|
|
2342
|
+
label: `Region ${Math.round(candidate.peak.centerHz)} Hz`,
|
|
2343
|
+
sourceId: "mixdown",
|
|
2344
|
+
// Proposals default to mixdown; user assigns sourceId on promotion
|
|
2345
|
+
enabled: true,
|
|
2346
|
+
timeScope: { kind: "global" },
|
|
2347
|
+
frequencyShape: [
|
|
2348
|
+
{
|
|
2349
|
+
startTime: 0,
|
|
2350
|
+
endTime: duration,
|
|
2351
|
+
lowHzStart: candidate.peak.lowHz,
|
|
2352
|
+
highHzStart: candidate.peak.highHz,
|
|
2353
|
+
lowHzEnd: candidate.peak.lowHz,
|
|
2354
|
+
highHzEnd: candidate.peak.highHz
|
|
2355
|
+
}
|
|
2356
|
+
],
|
|
2357
|
+
sortOrder: 0,
|
|
2358
|
+
provenance
|
|
2359
|
+
};
|
|
2360
|
+
}
|
|
2361
|
+
function filterOverlappingCandidates(candidates, minSeparationOctaves) {
|
|
2362
|
+
const filtered = [];
|
|
2363
|
+
for (const candidate of candidates) {
|
|
2364
|
+
let hasOverlap = false;
|
|
2365
|
+
for (const existing of filtered) {
|
|
2366
|
+
const octaveDiff = Math.abs(
|
|
2367
|
+
Math.log2(candidate.peak.centerHz / existing.peak.centerHz)
|
|
2368
|
+
);
|
|
2369
|
+
if (octaveDiff < minSeparationOctaves) {
|
|
2370
|
+
hasOverlap = true;
|
|
2371
|
+
break;
|
|
2372
|
+
}
|
|
2373
|
+
}
|
|
2374
|
+
if (!hasOverlap) {
|
|
2375
|
+
filtered.push(candidate);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
return filtered;
|
|
2379
|
+
}
|
|
2380
|
+
async function generateBandProposals(audio, duration, options = {}) {
|
|
2381
|
+
const startTime = performance.now();
|
|
2382
|
+
const config = {
|
|
2383
|
+
...PROPOSAL_DEFAULTS,
|
|
2384
|
+
...options.config
|
|
2385
|
+
};
|
|
2386
|
+
const cqtConfig = withCqtDefaults({
|
|
2387
|
+
binsPerOctave: 24,
|
|
2388
|
+
fMin: 32.7,
|
|
2389
|
+
fMax: Math.min(8372, audio.sampleRate / 2)
|
|
2390
|
+
// Cap at Nyquist
|
|
2391
|
+
});
|
|
2392
|
+
const cqt = await cqtSpectrogram(audio, cqtConfig, {
|
|
2393
|
+
isCancelled: options.isCancelled
|
|
2394
|
+
});
|
|
2395
|
+
if (options.isCancelled?.()) {
|
|
2396
|
+
throw new Error("@octoseq/mir: cancelled");
|
|
2397
|
+
}
|
|
2398
|
+
const harmonicResult = harmonicEnergy(cqt);
|
|
2399
|
+
const bassMotionResult = bassPitchMotion(cqt);
|
|
2400
|
+
const tonalResult = tonalStability(cqt);
|
|
2401
|
+
const cqtSignals = {
|
|
2402
|
+
harmonicEnergy: harmonicResult.values,
|
|
2403
|
+
bassPitchMotion: bassMotionResult.values,
|
|
2404
|
+
tonalStability: tonalResult.values
|
|
2405
|
+
};
|
|
2406
|
+
const peaks = detectSpectralPeaks(cqt, config);
|
|
2407
|
+
const avgSpectrum = computeAverageCqtSpectrum(cqt);
|
|
2408
|
+
const candidates = [];
|
|
2409
|
+
for (const peak of peaks) {
|
|
2410
|
+
if (options.isCancelled?.()) {
|
|
2411
|
+
throw new Error("@octoseq/mir: cancelled");
|
|
2412
|
+
}
|
|
2413
|
+
const candidate = scorePeak(peak, cqt, avgSpectrum, cqtSignals);
|
|
2414
|
+
if (candidate.salience >= config.minSalience) {
|
|
2415
|
+
candidates.push(candidate);
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
candidates.sort((a, b) => b.salience - a.salience);
|
|
2419
|
+
const filtered = filterOverlappingCandidates(candidates, config.minSeparationOctaves);
|
|
2420
|
+
const finalCandidates = filtered.slice(0, config.maxProposals);
|
|
2421
|
+
const proposals = finalCandidates.map((candidate, index) => {
|
|
2422
|
+
const band = createBandFromCandidate(candidate, duration);
|
|
2423
|
+
band.sortOrder = index;
|
|
2424
|
+
return {
|
|
2425
|
+
id: generateProposalId(),
|
|
2426
|
+
band,
|
|
2427
|
+
salience: candidate.salience,
|
|
2428
|
+
reason: candidate.reason,
|
|
2429
|
+
source: candidate.source,
|
|
2430
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2431
|
+
};
|
|
2432
|
+
});
|
|
2433
|
+
const endTime = performance.now();
|
|
2434
|
+
const meta = {
|
|
2435
|
+
backend: "cpu",
|
|
2436
|
+
usedGpu: false,
|
|
2437
|
+
timings: {
|
|
2438
|
+
totalMs: endTime - startTime,
|
|
2439
|
+
cpuMs: endTime - startTime
|
|
2440
|
+
}
|
|
2441
|
+
};
|
|
2442
|
+
return {
|
|
2443
|
+
kind: "bandProposals",
|
|
2444
|
+
proposals,
|
|
2445
|
+
meta
|
|
2446
|
+
};
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
// src/dsp/customSignalReduction.ts
|
|
2450
|
+
function logCompress2(x) {
|
|
2451
|
+
return Math.log1p(Math.max(0, x));
|
|
2452
|
+
}
|
|
2453
|
+
function movingAverage2(values, windowFrames) {
|
|
2454
|
+
if (windowFrames <= 1) return values;
|
|
2455
|
+
const n = values.length;
|
|
2456
|
+
const out = new Float32Array(n);
|
|
2457
|
+
const half = Math.floor(windowFrames / 2);
|
|
2458
|
+
const prefix = new Float64Array(n + 1);
|
|
2459
|
+
prefix[0] = 0;
|
|
2460
|
+
for (let i = 0; i < n; i++) {
|
|
2461
|
+
prefix[i + 1] = (prefix[i] ?? 0) + (values[i] ?? 0);
|
|
2462
|
+
}
|
|
2463
|
+
for (let i = 0; i < n; i++) {
|
|
2464
|
+
const start = Math.max(0, i - half);
|
|
2465
|
+
const end = Math.min(n, i + half + 1);
|
|
2466
|
+
const sum = (prefix[end] ?? 0) - (prefix[start] ?? 0);
|
|
2467
|
+
const count = Math.max(1, end - start);
|
|
2468
|
+
out[i] = sum / count;
|
|
2469
|
+
}
|
|
2470
|
+
return out;
|
|
2471
|
+
}
|
|
2472
|
+
function computeValueRange(values) {
|
|
2473
|
+
if (values.length === 0) {
|
|
2474
|
+
return { min: 0, max: 0 };
|
|
2475
|
+
}
|
|
2476
|
+
let min = values[0] ?? 0;
|
|
2477
|
+
let max = values[0] ?? 0;
|
|
2478
|
+
for (let i = 1; i < values.length; i++) {
|
|
2479
|
+
const v = values[i] ?? 0;
|
|
2480
|
+
if (v < min) min = v;
|
|
2481
|
+
if (v > max) max = v;
|
|
2482
|
+
}
|
|
2483
|
+
return { min, max };
|
|
2484
|
+
}
|
|
2485
|
+
function getBinRange(numBins, options) {
|
|
2486
|
+
const low = Math.max(0, options?.lowBin ?? 0);
|
|
2487
|
+
const high = Math.min(numBins, options?.highBin ?? numBins);
|
|
2488
|
+
return { low, high };
|
|
2489
|
+
}
|
|
2490
|
+
function reduceMean(input, options) {
|
|
2491
|
+
const nFrames = input.data.length;
|
|
2492
|
+
const values = new Float32Array(nFrames);
|
|
2493
|
+
for (let t = 0; t < nFrames; t++) {
|
|
2494
|
+
const frame = input.data[t];
|
|
2495
|
+
if (!frame || frame.length === 0) {
|
|
2496
|
+
values[t] = 0;
|
|
2497
|
+
continue;
|
|
2498
|
+
}
|
|
2499
|
+
const { low, high } = getBinRange(frame.length, options?.binRange);
|
|
2500
|
+
let sum = 0;
|
|
2501
|
+
const count = high - low;
|
|
2502
|
+
for (let k = low; k < high; k++) {
|
|
2503
|
+
sum += frame[k] ?? 0;
|
|
2504
|
+
}
|
|
2505
|
+
values[t] = count > 0 ? sum / count : 0;
|
|
2506
|
+
}
|
|
2507
|
+
return {
|
|
2508
|
+
times: input.times,
|
|
2509
|
+
values,
|
|
2510
|
+
valueRange: computeValueRange(values)
|
|
2511
|
+
};
|
|
2512
|
+
}
|
|
2513
|
+
function reduceMax(input, options) {
|
|
2514
|
+
const nFrames = input.data.length;
|
|
2515
|
+
const values = new Float32Array(nFrames);
|
|
2516
|
+
for (let t = 0; t < nFrames; t++) {
|
|
2517
|
+
const frame = input.data[t];
|
|
2518
|
+
if (!frame || frame.length === 0) {
|
|
2519
|
+
values[t] = 0;
|
|
2520
|
+
continue;
|
|
2521
|
+
}
|
|
2522
|
+
const { low, high } = getBinRange(frame.length, options?.binRange);
|
|
2523
|
+
let max = -Infinity;
|
|
2524
|
+
for (let k = low; k < high; k++) {
|
|
2525
|
+
const v = frame[k] ?? 0;
|
|
2526
|
+
if (v > max) max = v;
|
|
2527
|
+
}
|
|
2528
|
+
values[t] = max === -Infinity ? 0 : max;
|
|
11
2529
|
}
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
2530
|
+
return {
|
|
2531
|
+
times: input.times,
|
|
2532
|
+
values,
|
|
2533
|
+
valueRange: computeValueRange(values)
|
|
2534
|
+
};
|
|
2535
|
+
}
|
|
2536
|
+
function reduceSum(input, options) {
|
|
2537
|
+
const nFrames = input.data.length;
|
|
2538
|
+
const values = new Float32Array(nFrames);
|
|
2539
|
+
for (let t = 0; t < nFrames; t++) {
|
|
2540
|
+
const frame = input.data[t];
|
|
2541
|
+
if (!frame || frame.length === 0) {
|
|
2542
|
+
values[t] = 0;
|
|
2543
|
+
continue;
|
|
17
2544
|
}
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
);
|
|
2545
|
+
const { low, high } = getBinRange(frame.length, options?.binRange);
|
|
2546
|
+
let sum = 0;
|
|
2547
|
+
for (let k = low; k < high; k++) {
|
|
2548
|
+
sum += frame[k] ?? 0;
|
|
23
2549
|
}
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
2550
|
+
values[t] = sum;
|
|
2551
|
+
}
|
|
2552
|
+
return {
|
|
2553
|
+
times: input.times,
|
|
2554
|
+
values,
|
|
2555
|
+
valueRange: computeValueRange(values)
|
|
2556
|
+
};
|
|
2557
|
+
}
|
|
2558
|
+
function reduceVariance(input, options) {
|
|
2559
|
+
const nFrames = input.data.length;
|
|
2560
|
+
const values = new Float32Array(nFrames);
|
|
2561
|
+
for (let t = 0; t < nFrames; t++) {
|
|
2562
|
+
const frame = input.data[t];
|
|
2563
|
+
if (!frame || frame.length === 0) {
|
|
2564
|
+
values[t] = 0;
|
|
2565
|
+
continue;
|
|
29
2566
|
}
|
|
30
|
-
const
|
|
31
|
-
|
|
2567
|
+
const { low, high } = getBinRange(frame.length, options?.binRange);
|
|
2568
|
+
const count = high - low;
|
|
2569
|
+
if (count <= 1) {
|
|
2570
|
+
values[t] = 0;
|
|
2571
|
+
continue;
|
|
2572
|
+
}
|
|
2573
|
+
let sum = 0;
|
|
2574
|
+
for (let k = low; k < high; k++) {
|
|
2575
|
+
sum += frame[k] ?? 0;
|
|
2576
|
+
}
|
|
2577
|
+
const mean = sum / count;
|
|
2578
|
+
let variance = 0;
|
|
2579
|
+
for (let k = low; k < high; k++) {
|
|
2580
|
+
const d = (frame[k] ?? 0) - mean;
|
|
2581
|
+
variance += d * d;
|
|
2582
|
+
}
|
|
2583
|
+
values[t] = variance / count;
|
|
32
2584
|
}
|
|
33
|
-
|
|
2585
|
+
return {
|
|
2586
|
+
times: input.times,
|
|
2587
|
+
values,
|
|
2588
|
+
valueRange: computeValueRange(values)
|
|
2589
|
+
};
|
|
2590
|
+
}
|
|
2591
|
+
function reduceAmplitude(input, options) {
|
|
2592
|
+
return reduceSum(input, options);
|
|
2593
|
+
}
|
|
2594
|
+
function reduceSpectralFlux(input, options) {
|
|
2595
|
+
const nFrames = input.data.length;
|
|
2596
|
+
const values = new Float32Array(nFrames);
|
|
2597
|
+
const normalized = options?.spectralFlux?.normalized ?? true;
|
|
2598
|
+
if (nFrames === 0) {
|
|
2599
|
+
return {
|
|
2600
|
+
times: input.times,
|
|
2601
|
+
values,
|
|
2602
|
+
valueRange: { min: 0, max: 0 }
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
values[0] = 0;
|
|
2606
|
+
let prevNorm = null;
|
|
2607
|
+
for (let t = 0; t < nFrames; t++) {
|
|
2608
|
+
const frame = input.data[t];
|
|
2609
|
+
if (!frame || frame.length === 0) {
|
|
2610
|
+
values[t] = 0;
|
|
2611
|
+
prevNorm = null;
|
|
2612
|
+
continue;
|
|
2613
|
+
}
|
|
2614
|
+
const { low, high } = getBinRange(frame.length, options?.binRange);
|
|
2615
|
+
let curNorm;
|
|
2616
|
+
if (normalized) {
|
|
2617
|
+
let sum = 0;
|
|
2618
|
+
for (let k = low; k < high; k++) {
|
|
2619
|
+
sum += frame[k] ?? 0;
|
|
2620
|
+
}
|
|
2621
|
+
if (sum <= 0) {
|
|
2622
|
+
values[t] = 0;
|
|
2623
|
+
prevNorm = null;
|
|
2624
|
+
continue;
|
|
2625
|
+
}
|
|
2626
|
+
const inv = 1 / sum;
|
|
2627
|
+
curNorm = new Float32Array(high - low);
|
|
2628
|
+
for (let k = low; k < high; k++) {
|
|
2629
|
+
curNorm[k - low] = (frame[k] ?? 0) * inv;
|
|
2630
|
+
}
|
|
2631
|
+
} else {
|
|
2632
|
+
curNorm = new Float32Array(high - low);
|
|
2633
|
+
for (let k = low; k < high; k++) {
|
|
2634
|
+
curNorm[k - low] = frame[k] ?? 0;
|
|
2635
|
+
}
|
|
2636
|
+
}
|
|
2637
|
+
if (!prevNorm || prevNorm.length !== curNorm.length) {
|
|
2638
|
+
values[t] = 0;
|
|
2639
|
+
prevNorm = curNorm;
|
|
2640
|
+
continue;
|
|
2641
|
+
}
|
|
2642
|
+
let flux = 0;
|
|
2643
|
+
for (let k = 0; k < curNorm.length; k++) {
|
|
2644
|
+
flux += Math.abs((curNorm[k] ?? 0) - (prevNorm[k] ?? 0));
|
|
2645
|
+
}
|
|
2646
|
+
values[t] = flux;
|
|
2647
|
+
prevNorm = curNorm;
|
|
2648
|
+
}
|
|
2649
|
+
return {
|
|
2650
|
+
times: input.times,
|
|
2651
|
+
values,
|
|
2652
|
+
valueRange: computeValueRange(values)
|
|
2653
|
+
};
|
|
2654
|
+
}
|
|
2655
|
+
function reduceSpectralCentroid(input, options) {
|
|
2656
|
+
const nFrames = input.data.length;
|
|
2657
|
+
const values = new Float32Array(nFrames);
|
|
2658
|
+
for (let t = 0; t < nFrames; t++) {
|
|
2659
|
+
const frame = input.data[t];
|
|
2660
|
+
if (!frame || frame.length === 0) {
|
|
2661
|
+
values[t] = 0;
|
|
2662
|
+
continue;
|
|
2663
|
+
}
|
|
2664
|
+
const { low, high } = getBinRange(frame.length, options?.binRange);
|
|
2665
|
+
let num = 0;
|
|
2666
|
+
let den = 0;
|
|
2667
|
+
for (let k = low; k < high; k++) {
|
|
2668
|
+
const m = frame[k] ?? 0;
|
|
2669
|
+
if (m > 0) {
|
|
2670
|
+
num += k * m;
|
|
2671
|
+
den += m;
|
|
2672
|
+
}
|
|
2673
|
+
}
|
|
2674
|
+
values[t] = den > 0 ? num / den : 0;
|
|
2675
|
+
}
|
|
2676
|
+
return {
|
|
2677
|
+
times: input.times,
|
|
2678
|
+
values,
|
|
2679
|
+
valueRange: computeValueRange(values)
|
|
2680
|
+
};
|
|
2681
|
+
}
|
|
2682
|
+
function reduceOnsetStrength(input, options) {
|
|
2683
|
+
const nFrames = input.data.length;
|
|
2684
|
+
const values = new Float32Array(nFrames);
|
|
2685
|
+
const useLog = options?.onsetStrength?.useLog ?? true;
|
|
2686
|
+
const smoothMs = options?.onsetStrength?.smoothMs ?? 10;
|
|
2687
|
+
const diffMethod = options?.onsetStrength?.diffMethod ?? "rectified";
|
|
2688
|
+
if (nFrames === 0) {
|
|
2689
|
+
return {
|
|
2690
|
+
times: input.times,
|
|
2691
|
+
values,
|
|
2692
|
+
valueRange: { min: 0, max: 0 }
|
|
2693
|
+
};
|
|
2694
|
+
}
|
|
2695
|
+
values[0] = 0;
|
|
2696
|
+
for (let t = 1; t < nFrames; t++) {
|
|
2697
|
+
const cur = input.data[t];
|
|
2698
|
+
const prev = input.data[t - 1];
|
|
2699
|
+
if (!cur || !prev || cur.length === 0 || prev.length === 0) {
|
|
2700
|
+
values[t] = 0;
|
|
2701
|
+
continue;
|
|
2702
|
+
}
|
|
2703
|
+
const { low, high } = getBinRange(cur.length, options?.binRange);
|
|
2704
|
+
let sum = 0;
|
|
2705
|
+
let binsWithData = 0;
|
|
2706
|
+
for (let k = low; k < high; k++) {
|
|
2707
|
+
let a = cur[k] ?? 0;
|
|
2708
|
+
let b = prev[k] ?? 0;
|
|
2709
|
+
if (a > 0 || b > 0) {
|
|
2710
|
+
binsWithData++;
|
|
2711
|
+
if (useLog) {
|
|
2712
|
+
a = logCompress2(a);
|
|
2713
|
+
b = logCompress2(b);
|
|
2714
|
+
}
|
|
2715
|
+
const d = a - b;
|
|
2716
|
+
sum += diffMethod === "abs" ? Math.abs(d) : Math.max(0, d);
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
values[t] = binsWithData > 0 ? sum / binsWithData : 0;
|
|
2720
|
+
}
|
|
2721
|
+
if (smoothMs > 0 && nFrames >= 2) {
|
|
2722
|
+
const dt = (input.times[1] ?? 0) - (input.times[0] ?? 0);
|
|
2723
|
+
if (dt > 0) {
|
|
2724
|
+
const windowFrames = Math.max(1, Math.round(smoothMs / 1e3 / dt));
|
|
2725
|
+
const smoothed = movingAverage2(values, windowFrames | 1);
|
|
2726
|
+
return {
|
|
2727
|
+
times: input.times,
|
|
2728
|
+
values: smoothed,
|
|
2729
|
+
valueRange: computeValueRange(smoothed)
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
return {
|
|
2734
|
+
times: input.times,
|
|
2735
|
+
values,
|
|
2736
|
+
valueRange: computeValueRange(values)
|
|
2737
|
+
};
|
|
2738
|
+
}
|
|
2739
|
+
function reduce2DToSignal(input, algorithm, options) {
|
|
2740
|
+
switch (algorithm) {
|
|
2741
|
+
case "mean":
|
|
2742
|
+
return reduceMean(input, options);
|
|
2743
|
+
case "max":
|
|
2744
|
+
return reduceMax(input, options);
|
|
2745
|
+
case "sum":
|
|
2746
|
+
return reduceSum(input, options);
|
|
2747
|
+
case "variance":
|
|
2748
|
+
return reduceVariance(input, options);
|
|
2749
|
+
case "amplitude":
|
|
2750
|
+
return reduceAmplitude(input, options);
|
|
2751
|
+
case "spectralFlux":
|
|
2752
|
+
return reduceSpectralFlux(input, options);
|
|
2753
|
+
case "spectralCentroid":
|
|
2754
|
+
return reduceSpectralCentroid(input, options);
|
|
2755
|
+
case "onsetStrength":
|
|
2756
|
+
return reduceOnsetStrength(input, options);
|
|
2757
|
+
default:
|
|
2758
|
+
const _exhaustive = algorithm;
|
|
2759
|
+
throw new Error(`Unknown reduction algorithm: ${_exhaustive}`);
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
2762
|
+
function getReductionAlgorithmLabel(algorithm) {
|
|
2763
|
+
switch (algorithm) {
|
|
2764
|
+
case "mean":
|
|
2765
|
+
return "Mean";
|
|
2766
|
+
case "max":
|
|
2767
|
+
return "Maximum";
|
|
2768
|
+
case "sum":
|
|
2769
|
+
return "Sum";
|
|
2770
|
+
case "variance":
|
|
2771
|
+
return "Variance";
|
|
2772
|
+
case "amplitude":
|
|
2773
|
+
return "Amplitude Envelope";
|
|
2774
|
+
case "spectralFlux":
|
|
2775
|
+
return "Spectral Flux";
|
|
2776
|
+
case "spectralCentroid":
|
|
2777
|
+
return "Spectral Centroid";
|
|
2778
|
+
case "onsetStrength":
|
|
2779
|
+
return "Onset Strength";
|
|
2780
|
+
default:
|
|
2781
|
+
return String(algorithm);
|
|
2782
|
+
}
|
|
2783
|
+
}
|
|
2784
|
+
function getReductionAlgorithmDescription(algorithm) {
|
|
2785
|
+
switch (algorithm) {
|
|
2786
|
+
case "mean":
|
|
2787
|
+
return "Average value across all bins per frame";
|
|
2788
|
+
case "max":
|
|
2789
|
+
return "Maximum value across all bins per frame";
|
|
2790
|
+
case "sum":
|
|
2791
|
+
return "Sum of all bin values per frame";
|
|
2792
|
+
case "variance":
|
|
2793
|
+
return "Variance of bin values per frame";
|
|
2794
|
+
case "amplitude":
|
|
2795
|
+
return "Sum of magnitudes (energy envelope)";
|
|
2796
|
+
case "spectralFlux":
|
|
2797
|
+
return "Change between consecutive frames";
|
|
2798
|
+
case "spectralCentroid":
|
|
2799
|
+
return "Weighted center frequency";
|
|
2800
|
+
case "onsetStrength":
|
|
2801
|
+
return "Temporal derivative for onset detection";
|
|
2802
|
+
default:
|
|
2803
|
+
return "";
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
function applyPolarity(values, mode) {
|
|
2807
|
+
if (mode === "signed") {
|
|
2808
|
+
return values;
|
|
2809
|
+
}
|
|
2810
|
+
const result = new Float32Array(values.length);
|
|
2811
|
+
for (let i = 0; i < values.length; i++) {
|
|
2812
|
+
result[i] = Math.abs(values[i] ?? 0);
|
|
2813
|
+
}
|
|
2814
|
+
return result;
|
|
2815
|
+
}
|
|
2816
|
+
function getStabilizationWindowFrames(mode, frameTime) {
|
|
2817
|
+
const smoothingTimes = {
|
|
2818
|
+
none: 0,
|
|
2819
|
+
light: 0.01,
|
|
2820
|
+
// 10ms
|
|
2821
|
+
medium: 0.03,
|
|
2822
|
+
// 30ms
|
|
2823
|
+
heavy: 0.1
|
|
2824
|
+
// 100ms
|
|
2825
|
+
};
|
|
2826
|
+
const smoothMs = smoothingTimes[mode] * 1e3;
|
|
2827
|
+
if (smoothMs <= 0 || frameTime <= 0) return 1;
|
|
2828
|
+
return Math.max(1, Math.round(smoothMs / 1e3 / frameTime)) | 1;
|
|
2829
|
+
}
|
|
2830
|
+
function applyAttackRelease(values, times, attackTimeSec, releaseTimeSec) {
|
|
2831
|
+
const n = values.length;
|
|
2832
|
+
if (n === 0) return values;
|
|
2833
|
+
const out = new Float32Array(n);
|
|
2834
|
+
out[0] = values[0] ?? 0;
|
|
2835
|
+
for (let i = 1; i < n; i++) {
|
|
2836
|
+
const dt = (times[i] ?? 0) - (times[i - 1] ?? 0);
|
|
2837
|
+
if (dt <= 0) {
|
|
2838
|
+
out[i] = values[i] ?? 0;
|
|
2839
|
+
continue;
|
|
2840
|
+
}
|
|
2841
|
+
const current = values[i] ?? 0;
|
|
2842
|
+
const prev = out[i - 1] ?? 0;
|
|
2843
|
+
if (current > prev) {
|
|
2844
|
+
if (attackTimeSec > 0) {
|
|
2845
|
+
const alpha = 1 - Math.exp(-dt / attackTimeSec);
|
|
2846
|
+
out[i] = prev + alpha * (current - prev);
|
|
2847
|
+
} else {
|
|
2848
|
+
out[i] = current;
|
|
2849
|
+
}
|
|
2850
|
+
} else {
|
|
2851
|
+
if (releaseTimeSec > 0) {
|
|
2852
|
+
const alpha = 1 - Math.exp(-dt / releaseTimeSec);
|
|
2853
|
+
out[i] = prev + alpha * (current - prev);
|
|
2854
|
+
} else {
|
|
2855
|
+
out[i] = current;
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
return out;
|
|
2860
|
+
}
|
|
2861
|
+
function stabilizeSignal(values, times, options) {
|
|
2862
|
+
if (values.length === 0) return values;
|
|
2863
|
+
let result = values;
|
|
2864
|
+
if (options.mode !== "none" && times.length >= 2) {
|
|
2865
|
+
const dt = (times[1] ?? 0) - (times[0] ?? 0);
|
|
2866
|
+
if (dt > 0) {
|
|
2867
|
+
const windowFrames = getStabilizationWindowFrames(options.mode, dt);
|
|
2868
|
+
if (windowFrames > 1) {
|
|
2869
|
+
result = movingAverage2(result, windowFrames);
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
}
|
|
2873
|
+
if (options.envelopeMode === "attackRelease") {
|
|
2874
|
+
const attackSec = options.attackTimeSec ?? 0.01;
|
|
2875
|
+
const releaseSec = options.releaseTimeSec ?? 0.1;
|
|
2876
|
+
result = applyAttackRelease(result, times, attackSec, releaseSec);
|
|
2877
|
+
}
|
|
2878
|
+
return result;
|
|
2879
|
+
}
|
|
2880
|
+
function computePercentiles(values, percentiles) {
|
|
2881
|
+
if (values.length === 0) {
|
|
2882
|
+
return Object.fromEntries(percentiles.map((p) => [p, 0]));
|
|
2883
|
+
}
|
|
2884
|
+
const sorted = Float32Array.from(values).sort((a, b) => a - b);
|
|
2885
|
+
const n = sorted.length;
|
|
2886
|
+
const result = {};
|
|
2887
|
+
for (const p of percentiles) {
|
|
2888
|
+
const clamped = Math.max(0, Math.min(100, p));
|
|
2889
|
+
const index = clamped / 100 * (n - 1);
|
|
2890
|
+
const lower = Math.floor(index);
|
|
2891
|
+
const upper = Math.min(lower + 1, n - 1);
|
|
2892
|
+
const frac = index - lower;
|
|
2893
|
+
result[p] = (sorted[lower] ?? 0) * (1 - frac) + (sorted[upper] ?? 0) * frac;
|
|
2894
|
+
}
|
|
2895
|
+
return result;
|
|
2896
|
+
}
|
|
2897
|
+
function computeLocalStats(values, times, startTime, endTime) {
|
|
2898
|
+
const indices = [];
|
|
2899
|
+
for (let i = 0; i < times.length; i++) {
|
|
2900
|
+
const t = times[i] ?? 0;
|
|
2901
|
+
if (t >= startTime && t <= endTime) {
|
|
2902
|
+
indices.push(i);
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
if (indices.length === 0) {
|
|
2906
|
+
return { min: 0, max: 0, p5: 0, p95: 0 };
|
|
2907
|
+
}
|
|
2908
|
+
const viewportValues = new Float32Array(indices.length);
|
|
2909
|
+
for (let i = 0; i < indices.length; i++) {
|
|
2910
|
+
viewportValues[i] = values[indices[i] ?? 0] ?? 0;
|
|
2911
|
+
}
|
|
2912
|
+
const range = computeValueRange(viewportValues);
|
|
2913
|
+
const percentiles = computePercentiles(viewportValues, [5, 95]);
|
|
2914
|
+
return {
|
|
2915
|
+
min: range.min,
|
|
2916
|
+
max: range.max,
|
|
2917
|
+
p5: percentiles[5] ?? 0,
|
|
2918
|
+
p95: percentiles[95] ?? 0
|
|
2919
|
+
};
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
// src/dsp/eventToSignal.ts
|
|
2923
|
+
function generateGaussianEnvelope(centerSample, widthSamples, numSamples) {
|
|
2924
|
+
const envelope = new Float32Array(numSamples);
|
|
2925
|
+
const sigma = widthSamples / 4;
|
|
2926
|
+
const twoSigmaSq = 2 * sigma * sigma;
|
|
2927
|
+
const startSample = Math.max(0, Math.floor(centerSample - widthSamples));
|
|
2928
|
+
const endSample = Math.min(numSamples, Math.ceil(centerSample + widthSamples));
|
|
2929
|
+
for (let i = startSample; i < endSample; i++) {
|
|
2930
|
+
const diff = i - centerSample;
|
|
2931
|
+
envelope[i] = Math.exp(-(diff * diff) / twoSigmaSq);
|
|
2932
|
+
}
|
|
2933
|
+
return envelope;
|
|
2934
|
+
}
|
|
2935
|
+
function generateAttackDecayEnvelope(startSample, attackSamples, decaySamples, numSamples) {
|
|
2936
|
+
const envelope = new Float32Array(numSamples);
|
|
2937
|
+
const attackEnd = Math.min(numSamples, startSample + attackSamples);
|
|
2938
|
+
for (let i = startSample; i < attackEnd; i++) {
|
|
2939
|
+
if (i >= 0) {
|
|
2940
|
+
const t = (i - startSample) / attackSamples;
|
|
2941
|
+
envelope[i] = t;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
const decayStart = startSample + attackSamples;
|
|
2945
|
+
const decayEnd = Math.min(numSamples, decayStart + decaySamples);
|
|
2946
|
+
for (let i = decayStart; i < decayEnd; i++) {
|
|
2947
|
+
if (i >= 0) {
|
|
2948
|
+
const t = (i - decayStart) / decaySamples;
|
|
2949
|
+
envelope[i] = 1 - t;
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
return envelope;
|
|
2953
|
+
}
|
|
2954
|
+
function generateGateEnvelope(startSample, durationSamples, numSamples) {
|
|
2955
|
+
const envelope = new Float32Array(numSamples);
|
|
2956
|
+
const endSample = Math.min(numSamples, startSample + durationSamples);
|
|
2957
|
+
for (let i = Math.max(0, startSample); i < endSample; i++) {
|
|
2958
|
+
envelope[i] = 1;
|
|
2959
|
+
}
|
|
2960
|
+
return envelope;
|
|
2961
|
+
}
|
|
2962
|
+
function eventCount(events, windowSpec, options) {
|
|
2963
|
+
const { sampleRate, duration, normalize = false } = options;
|
|
2964
|
+
const numSamples = Math.ceil(duration * sampleRate);
|
|
2965
|
+
const values = new Float32Array(numSamples);
|
|
2966
|
+
const times = new Float32Array(numSamples);
|
|
2967
|
+
for (let i = 0; i < numSamples; i++) {
|
|
2968
|
+
times[i] = i / sampleRate;
|
|
2969
|
+
}
|
|
2970
|
+
const windowSamples = windowSpec.kind === "seconds" ? Math.ceil(windowSpec.windowSize * sampleRate) : windowSpec.windowSize;
|
|
2971
|
+
const halfWindow = Math.floor(windowSamples / 2);
|
|
2972
|
+
const sortedEvents = [...events].sort((a, b) => a.time - b.time);
|
|
2973
|
+
for (let i = 0; i < numSamples; i++) {
|
|
2974
|
+
const windowStart = (i - halfWindow) / sampleRate;
|
|
2975
|
+
const windowEnd = (i + halfWindow) / sampleRate;
|
|
2976
|
+
let count = 0;
|
|
2977
|
+
for (const event of sortedEvents) {
|
|
2978
|
+
if (event.time >= windowStart && event.time < windowEnd) {
|
|
2979
|
+
count++;
|
|
2980
|
+
}
|
|
2981
|
+
if (event.time >= windowEnd) break;
|
|
2982
|
+
}
|
|
2983
|
+
values[i] = count;
|
|
2984
|
+
}
|
|
2985
|
+
let min = 0;
|
|
2986
|
+
let max = 0;
|
|
2987
|
+
if (values.length > 0) {
|
|
2988
|
+
min = values[0];
|
|
2989
|
+
max = values[0];
|
|
2990
|
+
for (let i = 1; i < values.length; i++) {
|
|
2991
|
+
const v = values[i];
|
|
2992
|
+
if (v < min) min = v;
|
|
2993
|
+
if (v > max) max = v;
|
|
2994
|
+
}
|
|
2995
|
+
}
|
|
2996
|
+
if (normalize && max > min) {
|
|
2997
|
+
const range = max - min;
|
|
2998
|
+
for (let i = 0; i < values.length; i++) {
|
|
2999
|
+
values[i] = (values[i] - min) / range;
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
return { values, times, rawRange: { min, max } };
|
|
3003
|
+
}
|
|
3004
|
+
function eventDensity(events, windowSpec, options) {
|
|
3005
|
+
const result = eventCount(events, windowSpec, { ...options, normalize: false });
|
|
3006
|
+
const windowSeconds = windowSpec.kind === "seconds" ? windowSpec.windowSize : windowSpec.windowSize / options.sampleRate;
|
|
3007
|
+
for (let i = 0; i < result.values.length; i++) {
|
|
3008
|
+
result.values[i] = result.values[i] / windowSeconds;
|
|
3009
|
+
}
|
|
3010
|
+
let min = 0;
|
|
3011
|
+
let max = 0;
|
|
3012
|
+
if (result.values.length > 0) {
|
|
3013
|
+
min = result.values[0];
|
|
3014
|
+
max = result.values[0];
|
|
3015
|
+
for (let i = 1; i < result.values.length; i++) {
|
|
3016
|
+
const v = result.values[i];
|
|
3017
|
+
if (v < min) min = v;
|
|
3018
|
+
if (v > max) max = v;
|
|
3019
|
+
}
|
|
3020
|
+
}
|
|
3021
|
+
if (options.normalize && max > min) {
|
|
3022
|
+
const range = max - min;
|
|
3023
|
+
for (let i = 0; i < result.values.length; i++) {
|
|
3024
|
+
result.values[i] = (result.values[i] - min) / range;
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
return { ...result, rawRange: { min, max } };
|
|
3028
|
+
}
|
|
3029
|
+
function weightedSum(events, windowSpec, options) {
|
|
3030
|
+
const { sampleRate, duration, normalize = false } = options;
|
|
3031
|
+
const numSamples = Math.ceil(duration * sampleRate);
|
|
3032
|
+
const values = new Float32Array(numSamples);
|
|
3033
|
+
const times = new Float32Array(numSamples);
|
|
3034
|
+
for (let i = 0; i < numSamples; i++) {
|
|
3035
|
+
times[i] = i / sampleRate;
|
|
3036
|
+
}
|
|
3037
|
+
const windowSamples = windowSpec.kind === "seconds" ? Math.ceil(windowSpec.windowSize * sampleRate) : windowSpec.windowSize;
|
|
3038
|
+
const halfWindow = Math.floor(windowSamples / 2);
|
|
3039
|
+
const sortedEvents = [...events].sort((a, b) => a.time - b.time);
|
|
3040
|
+
for (let i = 0; i < numSamples; i++) {
|
|
3041
|
+
const windowStart = (i - halfWindow) / sampleRate;
|
|
3042
|
+
const windowEnd = (i + halfWindow) / sampleRate;
|
|
3043
|
+
let sum = 0;
|
|
3044
|
+
for (const event of sortedEvents) {
|
|
3045
|
+
if (event.time >= windowStart && event.time < windowEnd) {
|
|
3046
|
+
sum += event.weight ?? 1;
|
|
3047
|
+
}
|
|
3048
|
+
if (event.time >= windowEnd) break;
|
|
3049
|
+
}
|
|
3050
|
+
values[i] = sum;
|
|
3051
|
+
}
|
|
3052
|
+
let min = 0;
|
|
3053
|
+
let max = 0;
|
|
3054
|
+
if (values.length > 0) {
|
|
3055
|
+
min = values[0];
|
|
3056
|
+
max = values[0];
|
|
3057
|
+
for (let i = 1; i < values.length; i++) {
|
|
3058
|
+
const v = values[i];
|
|
3059
|
+
if (v < min) min = v;
|
|
3060
|
+
if (v > max) max = v;
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
if (normalize && max > min) {
|
|
3064
|
+
const range = max - min;
|
|
3065
|
+
for (let i = 0; i < values.length; i++) {
|
|
3066
|
+
values[i] = (values[i] - min) / range;
|
|
3067
|
+
}
|
|
3068
|
+
}
|
|
3069
|
+
return { values, times, rawRange: { min, max } };
|
|
3070
|
+
}
|
|
3071
|
+
function weightedMean(events, windowSpec, options) {
|
|
3072
|
+
const { sampleRate, duration, normalize = false } = options;
|
|
3073
|
+
const numSamples = Math.ceil(duration * sampleRate);
|
|
3074
|
+
const values = new Float32Array(numSamples);
|
|
3075
|
+
const times = new Float32Array(numSamples);
|
|
3076
|
+
for (let i = 0; i < numSamples; i++) {
|
|
3077
|
+
times[i] = i / sampleRate;
|
|
3078
|
+
}
|
|
3079
|
+
const windowSamples = windowSpec.kind === "seconds" ? Math.ceil(windowSpec.windowSize * sampleRate) : windowSpec.windowSize;
|
|
3080
|
+
const halfWindow = Math.floor(windowSamples / 2);
|
|
3081
|
+
const sortedEvents = [...events].sort((a, b) => a.time - b.time);
|
|
3082
|
+
for (let i = 0; i < numSamples; i++) {
|
|
3083
|
+
const windowStart = (i - halfWindow) / sampleRate;
|
|
3084
|
+
const windowEnd = (i + halfWindow) / sampleRate;
|
|
3085
|
+
let sum = 0;
|
|
3086
|
+
let count = 0;
|
|
3087
|
+
for (const event of sortedEvents) {
|
|
3088
|
+
if (event.time >= windowStart && event.time < windowEnd) {
|
|
3089
|
+
sum += event.weight ?? 1;
|
|
3090
|
+
count++;
|
|
3091
|
+
}
|
|
3092
|
+
if (event.time >= windowEnd) break;
|
|
3093
|
+
}
|
|
3094
|
+
values[i] = count > 0 ? sum / count : 0;
|
|
3095
|
+
}
|
|
3096
|
+
let min = 0;
|
|
3097
|
+
let max = 0;
|
|
3098
|
+
if (values.length > 0) {
|
|
3099
|
+
min = values[0];
|
|
3100
|
+
max = values[0];
|
|
3101
|
+
for (let i = 1; i < values.length; i++) {
|
|
3102
|
+
const v = values[i];
|
|
3103
|
+
if (v < min) min = v;
|
|
3104
|
+
if (v > max) max = v;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
if (normalize && max > min) {
|
|
3108
|
+
const range = max - min;
|
|
3109
|
+
for (let i = 0; i < values.length; i++) {
|
|
3110
|
+
values[i] = (values[i] - min) / range;
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
return { values, times, rawRange: { min, max } };
|
|
3114
|
+
}
|
|
3115
|
+
function eventEnvelope(events, shape, options) {
|
|
3116
|
+
const { sampleRate, duration, normalize = false } = options;
|
|
3117
|
+
const numSamples = Math.ceil(duration * sampleRate);
|
|
3118
|
+
const values = new Float32Array(numSamples);
|
|
3119
|
+
const times = new Float32Array(numSamples);
|
|
3120
|
+
for (let i = 0; i < numSamples; i++) {
|
|
3121
|
+
times[i] = i / sampleRate;
|
|
3122
|
+
}
|
|
3123
|
+
for (const event of events) {
|
|
3124
|
+
const eventSample = Math.floor(event.time * sampleRate);
|
|
3125
|
+
const weight = event.weight ?? 1;
|
|
3126
|
+
let envelope;
|
|
3127
|
+
switch (shape.kind) {
|
|
3128
|
+
case "impulse":
|
|
3129
|
+
if (eventSample >= 0 && eventSample < numSamples) {
|
|
3130
|
+
values[eventSample] = values[eventSample] + weight;
|
|
3131
|
+
}
|
|
3132
|
+
continue;
|
|
3133
|
+
case "gaussian":
|
|
3134
|
+
const widthSamples = shape.widthMs / 1e3 * sampleRate;
|
|
3135
|
+
envelope = generateGaussianEnvelope(eventSample, widthSamples, numSamples);
|
|
3136
|
+
break;
|
|
3137
|
+
case "attackDecay":
|
|
3138
|
+
const attackSamples = shape.attackMs / 1e3 * sampleRate;
|
|
3139
|
+
const decaySamples = shape.decayMs / 1e3 * sampleRate;
|
|
3140
|
+
envelope = generateAttackDecayEnvelope(eventSample, attackSamples, decaySamples, numSamples);
|
|
3141
|
+
break;
|
|
3142
|
+
case "gate":
|
|
3143
|
+
const durationSamples = event.duration ? event.duration * sampleRate : sampleRate * 0.1;
|
|
3144
|
+
envelope = generateGateEnvelope(eventSample, durationSamples, numSamples);
|
|
3145
|
+
break;
|
|
3146
|
+
default:
|
|
3147
|
+
continue;
|
|
3148
|
+
}
|
|
3149
|
+
for (let i = 0; i < numSamples; i++) {
|
|
3150
|
+
values[i] = values[i] + envelope[i] * weight;
|
|
3151
|
+
}
|
|
3152
|
+
}
|
|
3153
|
+
let min = 0;
|
|
3154
|
+
let max = 0;
|
|
3155
|
+
if (values.length > 0) {
|
|
3156
|
+
min = values[0];
|
|
3157
|
+
max = values[0];
|
|
3158
|
+
for (let i = 1; i < values.length; i++) {
|
|
3159
|
+
const v = values[i];
|
|
3160
|
+
if (v < min) min = v;
|
|
3161
|
+
if (v > max) max = v;
|
|
3162
|
+
}
|
|
3163
|
+
}
|
|
3164
|
+
if (normalize && max > min) {
|
|
3165
|
+
const range = max - min;
|
|
3166
|
+
for (let i = 0; i < values.length; i++) {
|
|
3167
|
+
values[i] = (values[i] - min) / range;
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
return { values, times, rawRange: { min, max } };
|
|
3171
|
+
}
|
|
3172
|
+
function eventsToSignal(events, params, options) {
|
|
3173
|
+
const defaultWindow = { kind: "seconds", windowSize: 0.5 };
|
|
3174
|
+
const defaultShape = { kind: "attackDecay", attackMs: 5, decayMs: 100 };
|
|
3175
|
+
switch (params.reducer) {
|
|
3176
|
+
case "eventCount":
|
|
3177
|
+
return eventCount(events, params.window ?? defaultWindow, options);
|
|
3178
|
+
case "eventDensity":
|
|
3179
|
+
return eventDensity(events, params.window ?? defaultWindow, options);
|
|
3180
|
+
case "weightedSum":
|
|
3181
|
+
return weightedSum(events, params.window ?? defaultWindow, options);
|
|
3182
|
+
case "weightedMean":
|
|
3183
|
+
return weightedMean(events, params.window ?? defaultWindow, options);
|
|
3184
|
+
case "envelope":
|
|
3185
|
+
return eventEnvelope(events, params.envelopeShape ?? defaultShape, options);
|
|
3186
|
+
default:
|
|
3187
|
+
throw new Error(`Unknown event reducer: ${params.reducer}`);
|
|
3188
|
+
}
|
|
3189
|
+
}
|
|
3190
|
+
|
|
3191
|
+
// src/dsp/signalTransforms.ts
|
|
3192
|
+
function smoothMovingAverage(values, windowMs, sampleRate) {
|
|
3193
|
+
const windowSamples = Math.max(1, Math.round(windowMs / 1e3 * sampleRate)) | 1;
|
|
3194
|
+
if (windowSamples <= 1) return values;
|
|
3195
|
+
const n = values.length;
|
|
3196
|
+
const result = new Float32Array(n);
|
|
3197
|
+
const halfWindow = Math.floor(windowSamples / 2);
|
|
3198
|
+
for (let i = 0; i < n; i++) {
|
|
3199
|
+
const start = Math.max(0, i - halfWindow);
|
|
3200
|
+
const end = Math.min(n, i + halfWindow + 1);
|
|
3201
|
+
let sum = 0;
|
|
3202
|
+
for (let j = start; j < end; j++) {
|
|
3203
|
+
sum += values[j];
|
|
3204
|
+
}
|
|
3205
|
+
result[i] = sum / (end - start);
|
|
3206
|
+
}
|
|
3207
|
+
return result;
|
|
3208
|
+
}
|
|
3209
|
+
function smoothExponential(values, timeConstantMs, sampleRate) {
|
|
3210
|
+
const n = values.length;
|
|
3211
|
+
if (n === 0) return values;
|
|
3212
|
+
const result = new Float32Array(n);
|
|
3213
|
+
const dt = 1 / sampleRate;
|
|
3214
|
+
const alpha = 1 - Math.exp(-dt / (timeConstantMs / 1e3));
|
|
3215
|
+
result[0] = values[0];
|
|
3216
|
+
for (let i = 1; i < n; i++) {
|
|
3217
|
+
result[i] = result[i - 1] + alpha * (values[i] - result[i - 1]);
|
|
3218
|
+
}
|
|
3219
|
+
return result;
|
|
3220
|
+
}
|
|
3221
|
+
function smoothGaussian(values, windowMs, sampleRate) {
|
|
3222
|
+
const windowSamples = Math.max(1, Math.round(windowMs / 1e3 * sampleRate));
|
|
3223
|
+
if (windowSamples <= 1) return values;
|
|
3224
|
+
const n = values.length;
|
|
3225
|
+
const result = new Float32Array(n);
|
|
3226
|
+
const sigma = windowSamples / 4;
|
|
3227
|
+
const halfWindow = Math.floor(windowSamples / 2);
|
|
3228
|
+
const kernel = new Float32Array(windowSamples);
|
|
3229
|
+
let kernelSum = 0;
|
|
3230
|
+
for (let i = 0; i < windowSamples; i++) {
|
|
3231
|
+
const x = i - halfWindow;
|
|
3232
|
+
kernel[i] = Math.exp(-(x * x) / (2 * sigma * sigma));
|
|
3233
|
+
kernelSum += kernel[i];
|
|
3234
|
+
}
|
|
3235
|
+
for (let i = 0; i < windowSamples; i++) {
|
|
3236
|
+
kernel[i] = kernel[i] / kernelSum;
|
|
3237
|
+
}
|
|
3238
|
+
for (let i = 0; i < n; i++) {
|
|
3239
|
+
let sum = 0;
|
|
3240
|
+
let weightSum = 0;
|
|
3241
|
+
for (let j = 0; j < windowSamples; j++) {
|
|
3242
|
+
const idx = i - halfWindow + j;
|
|
3243
|
+
if (idx >= 0 && idx < n) {
|
|
3244
|
+
sum += values[idx] * kernel[j];
|
|
3245
|
+
weightSum += kernel[j];
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
result[i] = weightSum > 0 ? sum / weightSum : values[i];
|
|
3249
|
+
}
|
|
3250
|
+
return result;
|
|
3251
|
+
}
|
|
3252
|
+
function normalizeMinMax2(values, targetMin = 0, targetMax = 1) {
|
|
3253
|
+
const n = values.length;
|
|
3254
|
+
if (n === 0) return values;
|
|
3255
|
+
let min = values[0];
|
|
3256
|
+
let max = values[0];
|
|
3257
|
+
for (let i = 1; i < n; i++) {
|
|
3258
|
+
const v = values[i];
|
|
3259
|
+
if (v < min) min = v;
|
|
3260
|
+
if (v > max) max = v;
|
|
3261
|
+
}
|
|
3262
|
+
if (max === min) {
|
|
3263
|
+
const result2 = new Float32Array(n);
|
|
3264
|
+
result2.fill((targetMin + targetMax) / 2);
|
|
3265
|
+
return result2;
|
|
3266
|
+
}
|
|
3267
|
+
const result = new Float32Array(n);
|
|
3268
|
+
const sourceRange = max - min;
|
|
3269
|
+
const targetRange = targetMax - targetMin;
|
|
3270
|
+
for (let i = 0; i < n; i++) {
|
|
3271
|
+
result[i] = targetMin + (values[i] - min) / sourceRange * targetRange;
|
|
3272
|
+
}
|
|
3273
|
+
return result;
|
|
3274
|
+
}
|
|
3275
|
+
function normalizeRobust(values, percentileLow = 5, percentileHigh = 95, targetMin = 0, targetMax = 1) {
|
|
3276
|
+
const n = values.length;
|
|
3277
|
+
if (n === 0) return values;
|
|
3278
|
+
const sorted = Array.from(values).sort((a, b) => a - b);
|
|
3279
|
+
const lowIdx = Math.floor(percentileLow / 100 * (n - 1));
|
|
3280
|
+
const highIdx = Math.floor(percentileHigh / 100 * (n - 1));
|
|
3281
|
+
const pLow = sorted[lowIdx];
|
|
3282
|
+
const pHigh = sorted[highIdx];
|
|
3283
|
+
if (pHigh === pLow) {
|
|
3284
|
+
const result2 = new Float32Array(n);
|
|
3285
|
+
result2.fill((targetMin + targetMax) / 2);
|
|
3286
|
+
return result2;
|
|
3287
|
+
}
|
|
3288
|
+
const result = new Float32Array(n);
|
|
3289
|
+
const sourceRange = pHigh - pLow;
|
|
3290
|
+
const targetRange = targetMax - targetMin;
|
|
3291
|
+
for (let i = 0; i < n; i++) {
|
|
3292
|
+
const normalized = (values[i] - pLow) / sourceRange;
|
|
3293
|
+
const clamped = Math.max(0, Math.min(1, normalized));
|
|
3294
|
+
result[i] = targetMin + clamped * targetRange;
|
|
3295
|
+
}
|
|
3296
|
+
return result;
|
|
3297
|
+
}
|
|
3298
|
+
function normalizeZScore(values) {
|
|
3299
|
+
const n = values.length;
|
|
3300
|
+
if (n === 0) return values;
|
|
3301
|
+
let sum = 0;
|
|
3302
|
+
for (let i = 0; i < n; i++) {
|
|
3303
|
+
sum += values[i];
|
|
3304
|
+
}
|
|
3305
|
+
const mean = sum / n;
|
|
3306
|
+
let variance = 0;
|
|
3307
|
+
for (let i = 0; i < n; i++) {
|
|
3308
|
+
const diff = values[i] - mean;
|
|
3309
|
+
variance += diff * diff;
|
|
3310
|
+
}
|
|
3311
|
+
const stdDev = Math.sqrt(variance / n);
|
|
3312
|
+
if (stdDev === 0) {
|
|
3313
|
+
const result2 = new Float32Array(n);
|
|
3314
|
+
result2.fill(0);
|
|
3315
|
+
return result2;
|
|
3316
|
+
}
|
|
3317
|
+
const result = new Float32Array(n);
|
|
3318
|
+
for (let i = 0; i < n; i++) {
|
|
3319
|
+
result[i] = (values[i] - mean) / stdDev;
|
|
3320
|
+
}
|
|
3321
|
+
return result;
|
|
3322
|
+
}
|
|
3323
|
+
function applyScale(values, scale, offset) {
|
|
3324
|
+
const n = values.length;
|
|
3325
|
+
const result = new Float32Array(n);
|
|
3326
|
+
for (let i = 0; i < n; i++) {
|
|
3327
|
+
result[i] = values[i] * scale + offset;
|
|
3328
|
+
}
|
|
3329
|
+
return result;
|
|
3330
|
+
}
|
|
3331
|
+
function applyPolarityTransform(values, mode) {
|
|
3332
|
+
if (mode === "signed") {
|
|
3333
|
+
return values;
|
|
3334
|
+
}
|
|
3335
|
+
const n = values.length;
|
|
3336
|
+
const result = new Float32Array(n);
|
|
3337
|
+
for (let i = 0; i < n; i++) {
|
|
3338
|
+
result[i] = Math.abs(values[i]);
|
|
3339
|
+
}
|
|
3340
|
+
return result;
|
|
3341
|
+
}
|
|
3342
|
+
function applyClamp(values, min, max) {
|
|
3343
|
+
const n = values.length;
|
|
3344
|
+
const result = new Float32Array(n);
|
|
3345
|
+
for (let i = 0; i < n; i++) {
|
|
3346
|
+
let v = values[i];
|
|
3347
|
+
if (min !== void 0 && v < min) v = min;
|
|
3348
|
+
if (max !== void 0 && v > max) v = max;
|
|
3349
|
+
result[i] = v;
|
|
3350
|
+
}
|
|
3351
|
+
return result;
|
|
3352
|
+
}
|
|
3353
|
+
function applyRemap(values, inputMin, inputMax, outputMin, outputMax, curve = "linear") {
|
|
3354
|
+
const n = values.length;
|
|
3355
|
+
const result = new Float32Array(n);
|
|
3356
|
+
const inputRange = inputMax - inputMin;
|
|
3357
|
+
const outputRange = outputMax - outputMin;
|
|
3358
|
+
if (inputRange === 0) {
|
|
3359
|
+
result.fill((outputMin + outputMax) / 2);
|
|
3360
|
+
return result;
|
|
3361
|
+
}
|
|
3362
|
+
for (let i = 0; i < n; i++) {
|
|
3363
|
+
let t = (values[i] - inputMin) / inputRange;
|
|
3364
|
+
t = Math.max(0, Math.min(1, t));
|
|
3365
|
+
switch (curve) {
|
|
3366
|
+
case "easeIn":
|
|
3367
|
+
t = t * t;
|
|
3368
|
+
break;
|
|
3369
|
+
case "easeOut":
|
|
3370
|
+
t = 1 - (1 - t) * (1 - t);
|
|
3371
|
+
break;
|
|
3372
|
+
case "ease":
|
|
3373
|
+
t = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
|
|
3374
|
+
break;
|
|
3375
|
+
}
|
|
3376
|
+
result[i] = outputMin + t * outputRange;
|
|
3377
|
+
}
|
|
3378
|
+
return result;
|
|
3379
|
+
}
|
|
3380
|
+
function applyTransformStep(values, step, context) {
|
|
3381
|
+
switch (step.kind) {
|
|
3382
|
+
case "smooth":
|
|
3383
|
+
switch (step.method) {
|
|
3384
|
+
case "movingAverage":
|
|
3385
|
+
return smoothMovingAverage(values, step.windowMs, context.sampleRate);
|
|
3386
|
+
case "exponential":
|
|
3387
|
+
return smoothExponential(values, step.timeConstantMs, context.sampleRate);
|
|
3388
|
+
case "gaussian":
|
|
3389
|
+
return smoothGaussian(values, step.windowMs, context.sampleRate);
|
|
3390
|
+
default:
|
|
3391
|
+
return values;
|
|
3392
|
+
}
|
|
3393
|
+
case "normalize":
|
|
3394
|
+
switch (step.method) {
|
|
3395
|
+
case "minMax":
|
|
3396
|
+
return normalizeMinMax2(values, step.targetMin, step.targetMax);
|
|
3397
|
+
case "robust":
|
|
3398
|
+
return normalizeRobust(
|
|
3399
|
+
values,
|
|
3400
|
+
step.percentileLow,
|
|
3401
|
+
step.percentileHigh,
|
|
3402
|
+
step.targetMin,
|
|
3403
|
+
step.targetMax
|
|
3404
|
+
);
|
|
3405
|
+
case "zScore":
|
|
3406
|
+
return normalizeZScore(values);
|
|
3407
|
+
default:
|
|
3408
|
+
return values;
|
|
3409
|
+
}
|
|
3410
|
+
case "scale":
|
|
3411
|
+
return applyScale(values, step.scale, step.offset);
|
|
3412
|
+
case "polarity":
|
|
3413
|
+
return applyPolarityTransform(values, step.mode);
|
|
3414
|
+
case "clamp":
|
|
3415
|
+
return applyClamp(values, step.min, step.max);
|
|
3416
|
+
case "remap":
|
|
3417
|
+
return applyRemap(
|
|
3418
|
+
values,
|
|
3419
|
+
step.inputMin,
|
|
3420
|
+
step.inputMax,
|
|
3421
|
+
step.outputMin,
|
|
3422
|
+
step.outputMax,
|
|
3423
|
+
step.curve
|
|
3424
|
+
);
|
|
3425
|
+
default:
|
|
3426
|
+
return values;
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
function applyTransformChain(values, chain, context) {
|
|
3430
|
+
let result = values;
|
|
3431
|
+
for (const step of chain) {
|
|
3432
|
+
result = applyTransformStep(result, step, context);
|
|
3433
|
+
}
|
|
3434
|
+
return result;
|
|
3435
|
+
}
|
|
3436
|
+
function getTransformLabel(step) {
|
|
3437
|
+
switch (step.kind) {
|
|
3438
|
+
case "smooth":
|
|
3439
|
+
switch (step.method) {
|
|
3440
|
+
case "movingAverage":
|
|
3441
|
+
return `Smooth (${step.windowMs}ms avg)`;
|
|
3442
|
+
case "exponential":
|
|
3443
|
+
return `Smooth (${step.timeConstantMs}ms exp)`;
|
|
3444
|
+
case "gaussian":
|
|
3445
|
+
return `Smooth (${step.windowMs}ms gauss)`;
|
|
3446
|
+
default:
|
|
3447
|
+
return "Smooth";
|
|
3448
|
+
}
|
|
3449
|
+
case "normalize":
|
|
3450
|
+
switch (step.method) {
|
|
3451
|
+
case "minMax":
|
|
3452
|
+
return `Normalize (${step.targetMin ?? 0}-${step.targetMax ?? 1})`;
|
|
3453
|
+
case "robust":
|
|
3454
|
+
return `Normalize (robust ${step.percentileLow ?? 5}-${step.percentileHigh ?? 95}%)`;
|
|
3455
|
+
case "zScore":
|
|
3456
|
+
return "Normalize (z-score)";
|
|
3457
|
+
default:
|
|
3458
|
+
return "Normalize";
|
|
3459
|
+
}
|
|
3460
|
+
case "scale":
|
|
3461
|
+
return step.offset !== 0 ? `Scale (\xD7${step.scale} + ${step.offset})` : `Scale (\xD7${step.scale})`;
|
|
3462
|
+
case "polarity":
|
|
3463
|
+
return step.mode === "magnitude" ? "Magnitude" : "Signed";
|
|
3464
|
+
case "clamp":
|
|
3465
|
+
if (step.min !== void 0 && step.max !== void 0) {
|
|
3466
|
+
return `Clamp (${step.min}-${step.max})`;
|
|
3467
|
+
} else if (step.min !== void 0) {
|
|
3468
|
+
return `Clamp (\u2265${step.min})`;
|
|
3469
|
+
} else if (step.max !== void 0) {
|
|
3470
|
+
return `Clamp (\u2264${step.max})`;
|
|
3471
|
+
}
|
|
3472
|
+
return "Clamp";
|
|
3473
|
+
case "remap":
|
|
3474
|
+
return `Remap (${step.inputMin}-${step.inputMax} \u2192 ${step.outputMin}-${step.outputMax})`;
|
|
3475
|
+
default:
|
|
3476
|
+
return "Unknown";
|
|
3477
|
+
}
|
|
3478
|
+
}
|
|
3479
|
+
function createDefaultTransform(kind) {
|
|
3480
|
+
switch (kind) {
|
|
3481
|
+
case "smooth":
|
|
3482
|
+
return { kind: "smooth", method: "movingAverage", windowMs: 10 };
|
|
3483
|
+
case "normalize":
|
|
3484
|
+
return { kind: "normalize", method: "minMax", targetMin: 0, targetMax: 1 };
|
|
3485
|
+
case "scale":
|
|
3486
|
+
return { kind: "scale", scale: 1, offset: 0 };
|
|
3487
|
+
case "polarity":
|
|
3488
|
+
return { kind: "polarity", mode: "signed" };
|
|
3489
|
+
case "clamp":
|
|
3490
|
+
return { kind: "clamp", min: 0, max: 1 };
|
|
3491
|
+
case "remap":
|
|
3492
|
+
return { kind: "remap", inputMin: 0, inputMax: 1, outputMin: 0, outputMax: 1 };
|
|
3493
|
+
default:
|
|
3494
|
+
return null;
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
34
3497
|
|
|
35
3498
|
// src/util/normalise.ts
|
|
36
3499
|
function normaliseForWaveform(data, options = {}) {
|
|
@@ -96,6 +3559,28 @@ function clampDb(db2d, minDb, maxDb) {
|
|
|
96
3559
|
return out;
|
|
97
3560
|
}
|
|
98
3561
|
|
|
3562
|
+
// src/dsp/resample.ts
|
|
3563
|
+
function resample(samples, fromRate, toRate) {
|
|
3564
|
+
if (fromRate === toRate) {
|
|
3565
|
+
return samples;
|
|
3566
|
+
}
|
|
3567
|
+
const ratio = fromRate / toRate;
|
|
3568
|
+
const newLength = Math.floor(samples.length / ratio);
|
|
3569
|
+
if (newLength <= 0) {
|
|
3570
|
+
return new Float32Array(0);
|
|
3571
|
+
}
|
|
3572
|
+
const result = new Float32Array(newLength);
|
|
3573
|
+
for (let i = 0; i < newLength; i++) {
|
|
3574
|
+
const srcIndex = i * ratio;
|
|
3575
|
+
const srcIndexFloor = Math.floor(srcIndex);
|
|
3576
|
+
const frac = srcIndex - srcIndexFloor;
|
|
3577
|
+
const a = samples[srcIndexFloor] ?? 0;
|
|
3578
|
+
const b = samples[srcIndexFloor + 1] ?? a;
|
|
3579
|
+
result[i] = a + frac * (b - a);
|
|
3580
|
+
}
|
|
3581
|
+
return result;
|
|
3582
|
+
}
|
|
3583
|
+
|
|
99
3584
|
// src/util/stats.ts
|
|
100
3585
|
function minMax(values) {
|
|
101
3586
|
const n = values.length >>> 0;
|
|
@@ -296,7 +3781,7 @@ function similarityFingerprintV1(a, b, weights = {}) {
|
|
|
296
3781
|
}
|
|
297
3782
|
|
|
298
3783
|
// src/search/searchTrackV1.ts
|
|
299
|
-
function
|
|
3784
|
+
function nowMs3() {
|
|
300
3785
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
301
3786
|
}
|
|
302
3787
|
function clamp01(x) {
|
|
@@ -305,7 +3790,7 @@ function clamp01(x) {
|
|
|
305
3790
|
return x;
|
|
306
3791
|
}
|
|
307
3792
|
async function searchTrackV1(params) {
|
|
308
|
-
const tStart =
|
|
3793
|
+
const tStart = nowMs3();
|
|
309
3794
|
const options = params.options ?? {};
|
|
310
3795
|
const hopSec = Math.max(5e-3, options.hopSec ?? 0.03);
|
|
311
3796
|
const threshold = clamp01(options.threshold ?? 0.75);
|
|
@@ -313,7 +3798,7 @@ async function searchTrackV1(params) {
|
|
|
313
3798
|
const qt1 = Math.max(params.queryRegion.t0, params.queryRegion.t1);
|
|
314
3799
|
const windowSec = Math.max(1e-3, qt1 - qt0);
|
|
315
3800
|
const minSpacingSec = Math.max(0, options.minCandidateSpacingSec ?? windowSec * 0.8);
|
|
316
|
-
const tFp0 =
|
|
3801
|
+
const tFp0 = nowMs3();
|
|
317
3802
|
const queryFp = fingerprintV1({
|
|
318
3803
|
t0: qt0,
|
|
319
3804
|
t1: qt1,
|
|
@@ -322,8 +3807,8 @@ async function searchTrackV1(params) {
|
|
|
322
3807
|
mfcc: params.mfcc,
|
|
323
3808
|
peakPick: options.queryPeakPick
|
|
324
3809
|
});
|
|
325
|
-
const fingerprintMs =
|
|
326
|
-
const scanStartMs =
|
|
3810
|
+
const fingerprintMs = nowMs3() - tFp0;
|
|
3811
|
+
const scanStartMs = nowMs3();
|
|
327
3812
|
const trackDuration = Math.max(
|
|
328
3813
|
params.mel.times.length ? params.mel.times[params.mel.times.length - 1] ?? 0 : 0,
|
|
329
3814
|
params.onsetEnvelope.times.length ? params.onsetEnvelope.times[params.onsetEnvelope.times.length - 1] ?? 0 : 0
|
|
@@ -363,7 +3848,7 @@ async function searchTrackV1(params) {
|
|
|
363
3848
|
sim[w] = clamp01(score);
|
|
364
3849
|
scannedWindows++;
|
|
365
3850
|
}
|
|
366
|
-
const scanMs =
|
|
3851
|
+
const scanMs = nowMs3() - scanStartMs;
|
|
367
3852
|
const events = peakPick(times, sim, {
|
|
368
3853
|
threshold,
|
|
369
3854
|
minIntervalSec: minSpacingSec,
|
|
@@ -379,7 +3864,7 @@ async function searchTrackV1(params) {
|
|
|
379
3864
|
windowEndSec
|
|
380
3865
|
};
|
|
381
3866
|
});
|
|
382
|
-
const totalMs =
|
|
3867
|
+
const totalMs = nowMs3() - tStart;
|
|
383
3868
|
return {
|
|
384
3869
|
times,
|
|
385
3870
|
similarity: sim,
|
|
@@ -613,7 +4098,7 @@ function scoreWithModelV1(model, x) {
|
|
|
613
4098
|
}
|
|
614
4099
|
|
|
615
4100
|
// src/search/searchTrackV1Guided.ts
|
|
616
|
-
function
|
|
4101
|
+
function nowMs4() {
|
|
617
4102
|
return typeof performance !== "undefined" ? performance.now() : Date.now();
|
|
618
4103
|
}
|
|
619
4104
|
function clamp013(x) {
|
|
@@ -780,7 +4265,7 @@ var SlidingMoments = class {
|
|
|
780
4265
|
}
|
|
781
4266
|
};
|
|
782
4267
|
async function searchTrackV1Guided(params) {
|
|
783
|
-
const tStart =
|
|
4268
|
+
const tStart = nowMs4();
|
|
784
4269
|
const options = params.options ?? {};
|
|
785
4270
|
const hopSec = Math.max(5e-3, options.hopSec ?? 0.03);
|
|
786
4271
|
const threshold = clamp013(options.threshold ?? 0.75);
|
|
@@ -799,7 +4284,7 @@ async function searchTrackV1Guided(params) {
|
|
|
799
4284
|
positives: modelDecision.positives.length,
|
|
800
4285
|
negatives: modelDecision.negatives.length
|
|
801
4286
|
} : { kind: "baseline", positives: 0, negatives: 0 };
|
|
802
|
-
const tPrep0 =
|
|
4287
|
+
const tPrep0 = nowMs4();
|
|
803
4288
|
const timesFrames = params.mel.times;
|
|
804
4289
|
const nFrames = timesFrames.length;
|
|
805
4290
|
const trackDuration = Math.max(
|
|
@@ -810,7 +4295,7 @@ async function searchTrackV1Guided(params) {
|
|
|
810
4295
|
const times = new Float32Array(nWindows);
|
|
811
4296
|
const scores = new Float32Array(nWindows);
|
|
812
4297
|
if (nWindows === 0) {
|
|
813
|
-
const totalMs2 =
|
|
4298
|
+
const totalMs2 = nowMs4() - tStart;
|
|
814
4299
|
return {
|
|
815
4300
|
times,
|
|
816
4301
|
scores,
|
|
@@ -899,7 +4384,7 @@ async function searchTrackV1Guided(params) {
|
|
|
899
4384
|
}
|
|
900
4385
|
const peakPrefix = new Uint32Array(nFrames + 1);
|
|
901
4386
|
for (let i = 0; i < nFrames; i++) peakPrefix[i + 1] = (peakPrefix[i] ?? 0) + (isPeak[i] ?? 0);
|
|
902
|
-
const fingerprintMs =
|
|
4387
|
+
const fingerprintMs = nowMs4() - tPrep0;
|
|
903
4388
|
const addMelFrame = (frame, sum, sumSq) => {
|
|
904
4389
|
const row = melBands[frame];
|
|
905
4390
|
const s = melScale[frame] ?? 1;
|
|
@@ -1085,7 +4570,7 @@ async function searchTrackV1Guided(params) {
|
|
|
1085
4570
|
};
|
|
1086
4571
|
let skippedWindows = 0;
|
|
1087
4572
|
let scannedWindows = 0;
|
|
1088
|
-
const scanStartMs =
|
|
4573
|
+
const scanStartMs = nowMs4();
|
|
1089
4574
|
let curveKind = "similarity";
|
|
1090
4575
|
let modelExplain = baselineExplain;
|
|
1091
4576
|
let modelMs = 0;
|
|
@@ -1108,7 +4593,7 @@ async function searchTrackV1Guided(params) {
|
|
|
1108
4593
|
if (modelDecision.kind === "baseline") {
|
|
1109
4594
|
runBaselineSimilarityScan();
|
|
1110
4595
|
} else {
|
|
1111
|
-
const tModel0 =
|
|
4596
|
+
const tModel0 = nowMs4();
|
|
1112
4597
|
curveKind = "confidence";
|
|
1113
4598
|
try {
|
|
1114
4599
|
const dim = layout.dim;
|
|
@@ -1173,10 +4658,10 @@ async function searchTrackV1Guided(params) {
|
|
|
1173
4658
|
zInvStd = null;
|
|
1174
4659
|
runBaselineSimilarityScan();
|
|
1175
4660
|
} finally {
|
|
1176
|
-
modelMs =
|
|
4661
|
+
modelMs = nowMs4() - tModel0;
|
|
1177
4662
|
}
|
|
1178
4663
|
}
|
|
1179
|
-
const scanMs =
|
|
4664
|
+
const scanMs = nowMs4() - scanStartMs;
|
|
1180
4665
|
const events = peakPick(times, scores, {
|
|
1181
4666
|
threshold,
|
|
1182
4667
|
minIntervalSec: minSpacingSec,
|
|
@@ -1203,7 +4688,7 @@ async function searchTrackV1Guided(params) {
|
|
|
1203
4688
|
};
|
|
1204
4689
|
}
|
|
1205
4690
|
}
|
|
1206
|
-
const totalMs =
|
|
4691
|
+
const totalMs = nowMs4() - tStart;
|
|
1207
4692
|
return {
|
|
1208
4693
|
times,
|
|
1209
4694
|
scores,
|
|
@@ -1229,6 +4714,6 @@ function helloMir(name = "world") {
|
|
|
1229
4714
|
return `Hello, ${name} from @octoseq/mir v${MIR_VERSION}`;
|
|
1230
4715
|
}
|
|
1231
4716
|
|
|
1232
|
-
export { MIR_VERSION, MirGPU, clampDb, fingerprintToVectorV1, fingerprintV1, helloMir, minMax, normaliseForWaveform, searchTrackV1, searchTrackV1Guided, similarityFingerprintV1, spectrogramToDb };
|
|
4717
|
+
export { DEFAULT_PEAK_PICKING_PARAMS, MIR_VERSION, MirGPU, addBandToStructure, allFrequencyBoundsAt, applyBandMaskToCqt, applyBandMaskToSpectrogram, applyHysteresisGate, applyPolarity, applyTransformChain, applyTransformStep, bandAmplitudeEnvelope, bandBeatCandidates, bandCqtBassPitchMotion, bandCqtHarmonicEnergy, bandCqtTonalStability, bandOnsetPeaks, bandOnsetStrength, bandSpectralCentroid, bandSpectralFlux, bandsActiveAt, binToHz, clampDb, computeAdaptiveThreshold, computeBandMaskAtTime, computeBeatPosition, computeBeatPositionFromStructure, computeFrameAmplitude, computeFrameEnergy, computeLocalStats, computePercentiles, computePhaseHypotheses, createBandStructure, createConstantBand, createDefaultTransform, createMusicalTimeStructure, createSectionedBand, createSegmentFromGrid, createStandardBands, eventCount, eventDensity, eventEnvelope, eventsToSignal, findBandById, findSegmentAtTime, fingerprintToVectorV1, fingerprintV1, frequencyBoundsAt, generateBandId, generateBandProposals, generateBeatTimes, generateSegmentBeatTimes, generateSegmentId, getBandCqtFunctionLabel, getBandEventFunctionLabel, getBandMirFunctionLabel, getReductionAlgorithmDescription, getReductionAlgorithmLabel, getTransformLabel, helloMir, hzToBin, keyframesFromBand, mergeAdjacentSegments, minMax, moveKeyframeTime, normaliseForWaveform, pickPeaks, pickPeaksAdaptive, reduce2DToSignal, removeBandFromStructure, removeKeyframe, resample, runBandCqtBatch, runBandEventsBatch, runBandMirBatch, searchTrackV1, searchTrackV1Guided, segmentsFromKeyframes, similarityFingerprintV1, sortBands, sortFrequencySegments, sortSegments, spectrogramToDb, splitBandSegmentAt, splitSegment, stabilizeSignal, touchStructure, updateBandInStructure, updateKeyframe, validateBandStructure, validateFrequencyBand, validateFrequencySegments, validateSegments, weightedMean, weightedSum };
|
|
1233
4718
|
//# sourceMappingURL=index.js.map
|
|
1234
4719
|
//# sourceMappingURL=index.js.map
|