@waveform-playlist/spectrogram 11.3.1 → 13.0.0

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.
@@ -1,606 +0,0 @@
1
- // src/computation/fft.ts
2
- import FFT from "fft.js";
3
- var fftInstances = /* @__PURE__ */ new Map();
4
- var complexBuffers = /* @__PURE__ */ new Map();
5
- function getFftInstance(size) {
6
- let instance = fftInstances.get(size);
7
- if (!instance) {
8
- instance = new FFT(size);
9
- fftInstances.set(size, instance);
10
- complexBuffers.set(size, instance.createComplexArray());
11
- }
12
- return instance;
13
- }
14
- function getComplexBuffer(size) {
15
- const buffer = complexBuffers.get(size);
16
- if (!buffer) {
17
- throw new Error(`No complex buffer for size ${size}. Call getFftInstance first.`);
18
- }
19
- return buffer;
20
- }
21
- function fftMagnitudeDb(real, out) {
22
- const n = real.length;
23
- const f = getFftInstance(n);
24
- const complexOut = getComplexBuffer(n);
25
- f.realTransform(complexOut, real);
26
- const half = n >> 1;
27
- for (let i = 0; i < half; i++) {
28
- const re = complexOut[i * 2];
29
- const im = complexOut[i * 2 + 1];
30
- let db = 20 * Math.log10(Math.sqrt(re * re + im * im) + 1e-10);
31
- if (db < -160) db = -160;
32
- out[i] = db;
33
- }
34
- }
35
-
36
- // src/computation/windowFunctions.ts
37
- function getWindowFunction(name, size, alpha) {
38
- const window = new Float32Array(size);
39
- const N = size;
40
- switch (name) {
41
- case "rectangular":
42
- for (let i = 0; i < size; i++) window[i] = 1;
43
- break;
44
- case "bartlett":
45
- for (let i = 0; i < size; i++) {
46
- window[i] = 1 - Math.abs((2 * i - N) / N);
47
- }
48
- break;
49
- case "hann":
50
- for (let i = 0; i < size; i++) {
51
- window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / N));
52
- }
53
- break;
54
- case "hamming":
55
- for (let i = 0; i < size; i++) {
56
- const a = alpha ?? 0.54;
57
- window[i] = a - (1 - a) * Math.cos(2 * Math.PI * i / N);
58
- }
59
- break;
60
- case "blackman": {
61
- const a0 = 0.42;
62
- const a1 = 0.5;
63
- const a2 = 0.08;
64
- for (let i = 0; i < size; i++) {
65
- window[i] = a0 - a1 * Math.cos(2 * Math.PI * i / N) + a2 * Math.cos(4 * Math.PI * i / N);
66
- }
67
- break;
68
- }
69
- case "blackman-harris": {
70
- const c0 = 0.35875;
71
- const c1 = 0.48829;
72
- const c2 = 0.14128;
73
- const c3 = 0.01168;
74
- for (let i = 0; i < size; i++) {
75
- window[i] = c0 - c1 * Math.cos(2 * Math.PI * i / N) + c2 * Math.cos(4 * Math.PI * i / N) - c3 * Math.cos(6 * Math.PI * i / N);
76
- }
77
- break;
78
- }
79
- default:
80
- console.warn(`[spectrogram] Unknown window function "${name}", falling back to hann`);
81
- for (let i = 0; i < size; i++) {
82
- window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / N));
83
- }
84
- }
85
- let sum = 0;
86
- for (let i = 0; i < size; i++) sum += window[i];
87
- if (sum > 0) {
88
- const scale = 2 / sum;
89
- for (let i = 0; i < size; i++) window[i] *= scale;
90
- }
91
- return window;
92
- }
93
-
94
- // src/computation/frequencyScales.ts
95
- function linearScale(f, minF, maxF) {
96
- if (maxF === minF) return 0;
97
- return (f - minF) / (maxF - minF);
98
- }
99
- function logarithmicScale(f, minF, maxF) {
100
- const logMin = Math.log2(Math.max(minF, 1));
101
- const logMax = Math.log2(maxF);
102
- if (logMax === logMin) return 0;
103
- return (Math.log2(Math.max(f, 1)) - logMin) / (logMax - logMin);
104
- }
105
- function hzToMel(f) {
106
- return 2595 * Math.log10(1 + f / 700);
107
- }
108
- function melScale(f, minF, maxF) {
109
- const melMin = hzToMel(minF);
110
- const melMax = hzToMel(maxF);
111
- if (melMax === melMin) return 0;
112
- return (hzToMel(f) - melMin) / (melMax - melMin);
113
- }
114
- function hzToBark(f) {
115
- return 13 * Math.atan(76e-5 * f) + 3.5 * Math.atan((f / 7500) ** 2);
116
- }
117
- function barkScale(f, minF, maxF) {
118
- const barkMin = hzToBark(minF);
119
- const barkMax = hzToBark(maxF);
120
- if (barkMax === barkMin) return 0;
121
- return (hzToBark(f) - barkMin) / (barkMax - barkMin);
122
- }
123
- function hzToErb(f) {
124
- return 21.4 * Math.log10(1 + 437e-5 * f);
125
- }
126
- function erbScale(f, minF, maxF) {
127
- const erbMin = hzToErb(minF);
128
- const erbMax = hzToErb(maxF);
129
- if (erbMax === erbMin) return 0;
130
- return (hzToErb(f) - erbMin) / (erbMax - erbMin);
131
- }
132
- function getFrequencyScale(name) {
133
- switch (name) {
134
- case "logarithmic":
135
- return logarithmicScale;
136
- case "mel":
137
- return melScale;
138
- case "bark":
139
- return barkScale;
140
- case "erb":
141
- return erbScale;
142
- case "linear":
143
- return linearScale;
144
- default:
145
- console.warn(`[spectrogram] Unknown frequency scale "${name}", falling back to linear`);
146
- return linearScale;
147
- }
148
- }
149
-
150
- // src/worker/spectrogram.worker.ts
151
- var canvasRegistry = /* @__PURE__ */ new Map();
152
- var audioDataRegistry = /* @__PURE__ */ new Map();
153
- var MAX_CACHE_ENTRIES = 16;
154
- var fftCache = /* @__PURE__ */ new Map();
155
- function evictLRUCacheEntries(keepKey) {
156
- while (fftCache.size >= MAX_CACHE_ENTRIES) {
157
- let deleted = false;
158
- for (const key of fftCache.keys()) {
159
- if (key !== keepKey) {
160
- fftCache.delete(key);
161
- deleted = true;
162
- break;
163
- }
164
- }
165
- if (!deleted) break;
166
- }
167
- }
168
- function touchCacheEntry(key) {
169
- const entry = fftCache.get(key);
170
- if (entry) {
171
- fftCache.delete(key);
172
- fftCache.set(key, entry);
173
- }
174
- }
175
- function generateCacheKey(params) {
176
- const { compute: c } = params;
177
- return `${params.clipId}:${params.channelIndex}:${params.offsetSamples}:${params.durationSamples}:${params.sampleRate}:${c.fftSize ?? ""}:${c.zeroPaddingFactor ?? ""}:${c.hopSize ?? ""}:${c.windowFunction ?? ""}:${c.alpha ?? ""}:${params.mono ? 1 : 0}`;
178
- }
179
- var latestGeneration = 0;
180
- var FRAMES_PER_YIELD = 2e3;
181
- function isGenerationStale(generation) {
182
- return generation < latestGeneration;
183
- }
184
- function yieldToMessageQueue() {
185
- return new Promise((resolve) => setTimeout(resolve, 0));
186
- }
187
- async function computeFromChannelData(channelData, config, sampleRate, offsetSamples, durationSamples, generation) {
188
- const windowSize = config.fftSize ?? 2048;
189
- const zeroPaddingFactor = config.zeroPaddingFactor ?? 2;
190
- const actualFftSize = windowSize * zeroPaddingFactor;
191
- const hopSize = config.hopSize ?? Math.floor(windowSize / 4);
192
- const windowName = config.windowFunction ?? "hann";
193
- const gainDb = config.gainDb ?? 20;
194
- const rangeDb = config.rangeDb ?? 80;
195
- const alpha = config.alpha;
196
- const frequencyBinCount = actualFftSize >> 1;
197
- const totalSamples = durationSamples;
198
- const window = getWindowFunction(windowName, windowSize, alpha);
199
- const frameCount = Math.max(1, Math.floor((totalSamples - windowSize) / hopSize) + 1);
200
- const data = new Float32Array(frameCount * frequencyBinCount);
201
- const real = new Float32Array(actualFftSize);
202
- const dbBuf = new Float32Array(frequencyBinCount);
203
- for (let frame = 0; frame < frameCount; frame++) {
204
- if (frame > 0 && frame % FRAMES_PER_YIELD === 0) {
205
- await yieldToMessageQueue();
206
- if (isGenerationStale(generation)) return null;
207
- }
208
- const start = offsetSamples + frame * hopSize;
209
- for (let i = 0; i < windowSize; i++) {
210
- const sampleIdx = start + i;
211
- real[i] = sampleIdx < channelData.length ? channelData[sampleIdx] * window[i] : 0;
212
- }
213
- for (let i = windowSize; i < actualFftSize; i++) {
214
- real[i] = 0;
215
- }
216
- fftMagnitudeDb(real, dbBuf);
217
- data.set(dbBuf, frame * frequencyBinCount);
218
- }
219
- return {
220
- fftSize: actualFftSize,
221
- windowSize,
222
- frequencyBinCount,
223
- sampleRate,
224
- hopSize,
225
- frameCount,
226
- data,
227
- gainDb,
228
- rangeDb
229
- };
230
- }
231
- async function computeMonoFromChannels(channels, config, sampleRate, offsetSamples, durationSamples, generation) {
232
- if (channels.length === 1) {
233
- return computeFromChannelData(
234
- channels[0],
235
- config,
236
- sampleRate,
237
- offsetSamples,
238
- durationSamples,
239
- generation
240
- );
241
- }
242
- const windowSize = config.fftSize ?? 2048;
243
- const zeroPaddingFactor = config.zeroPaddingFactor ?? 2;
244
- const actualFftSize = windowSize * zeroPaddingFactor;
245
- const hopSize = config.hopSize ?? Math.floor(windowSize / 4);
246
- const windowName = config.windowFunction ?? "hann";
247
- const gainDb = config.gainDb ?? 20;
248
- const rangeDb = config.rangeDb ?? 80;
249
- const alpha = config.alpha;
250
- const frequencyBinCount = actualFftSize >> 1;
251
- const numChannels = channels.length;
252
- const window = getWindowFunction(windowName, windowSize, alpha);
253
- const frameCount = Math.max(1, Math.floor((durationSamples - windowSize) / hopSize) + 1);
254
- const data = new Float32Array(frameCount * frequencyBinCount);
255
- const real = new Float32Array(actualFftSize);
256
- const dbBuf = new Float32Array(frequencyBinCount);
257
- for (let frame = 0; frame < frameCount; frame++) {
258
- if (frame > 0 && frame % FRAMES_PER_YIELD === 0) {
259
- await yieldToMessageQueue();
260
- if (isGenerationStale(generation)) return null;
261
- }
262
- const start = offsetSamples + frame * hopSize;
263
- for (let i = 0; i < windowSize; i++) {
264
- const sampleIdx = start + i;
265
- let sum = 0;
266
- for (let ch = 0; ch < numChannels; ch++) {
267
- sum += sampleIdx < channels[ch].length ? channels[ch][sampleIdx] : 0;
268
- }
269
- real[i] = sum / numChannels * window[i];
270
- }
271
- for (let i = windowSize; i < actualFftSize; i++) {
272
- real[i] = 0;
273
- }
274
- fftMagnitudeDb(real, dbBuf);
275
- data.set(dbBuf, frame * frequencyBinCount);
276
- }
277
- return {
278
- fftSize: actualFftSize,
279
- windowSize,
280
- frequencyBinCount,
281
- sampleRate,
282
- hopSize,
283
- frameCount,
284
- data,
285
- gainDb,
286
- rangeDb
287
- };
288
- }
289
- function renderSpectrogramToCanvas(specData, canvasIds, canvasWidths, canvasHeight, devicePixelRatio, samplesPerPixel, colorLUT, scaleFn, minFrequency, maxFrequency, isNonLinear, globalPixelOffsets, gainDbOverride, rangeDbOverride, sampleOffset = 0) {
290
- const { frequencyBinCount, frameCount, hopSize, sampleRate } = specData;
291
- const gainDb = gainDbOverride ?? specData.gainDb;
292
- const rawRangeDb = rangeDbOverride ?? specData.rangeDb;
293
- const rangeDb = rawRangeDb === 0 ? 1 : rawRangeDb;
294
- const maxF = maxFrequency > 0 ? maxFrequency : sampleRate / 2;
295
- const binToFreq = (bin) => bin / frequencyBinCount * (sampleRate / 2);
296
- let accumulatedOffset = 0;
297
- for (let chunkIdx = 0; chunkIdx < canvasIds.length; chunkIdx++) {
298
- const canvasId = canvasIds[chunkIdx];
299
- const offscreen = canvasRegistry.get(canvasId);
300
- if (!offscreen) {
301
- console.warn(`[spectrogram-worker] Canvas "${canvasId}" not found in registry`);
302
- if (!globalPixelOffsets) accumulatedOffset += canvasWidths[chunkIdx];
303
- continue;
304
- }
305
- const canvasWidth = canvasWidths[chunkIdx];
306
- const globalPixelOffset = globalPixelOffsets ? globalPixelOffsets[chunkIdx] : accumulatedOffset;
307
- offscreen.width = canvasWidth * devicePixelRatio;
308
- offscreen.height = canvasHeight * devicePixelRatio;
309
- const ctx = offscreen.getContext("2d");
310
- if (!ctx) {
311
- console.warn(`[spectrogram-worker] getContext('2d') returned null for canvas "${canvasId}"`);
312
- if (!globalPixelOffsets) accumulatedOffset += canvasWidth;
313
- continue;
314
- }
315
- ctx.resetTransform();
316
- ctx.clearRect(0, 0, offscreen.width, offscreen.height);
317
- ctx.imageSmoothingEnabled = false;
318
- const imgData = ctx.createImageData(canvasWidth, canvasHeight);
319
- const pixels = imgData.data;
320
- for (let x = 0; x < canvasWidth; x++) {
321
- const globalX = globalPixelOffset + x;
322
- const samplePos = globalX * samplesPerPixel - sampleOffset;
323
- const frame = Math.floor(samplePos / hopSize);
324
- if (frame < 0 || frame >= frameCount) continue;
325
- const frameOffset = frame * frequencyBinCount;
326
- for (let y = 0; y < canvasHeight; y++) {
327
- const normalizedY = 1 - y / canvasHeight;
328
- let bin = Math.floor(normalizedY * frequencyBinCount);
329
- if (isNonLinear) {
330
- let lo = 0;
331
- let hi = frequencyBinCount - 1;
332
- while (lo < hi) {
333
- const mid = lo + hi >> 1;
334
- const freq = binToFreq(mid);
335
- const scaled = scaleFn(freq, minFrequency, maxF);
336
- if (scaled < normalizedY) {
337
- lo = mid + 1;
338
- } else {
339
- hi = mid;
340
- }
341
- }
342
- bin = lo;
343
- }
344
- if (bin < 0 || bin >= frequencyBinCount) continue;
345
- const db = specData.data[frameOffset + bin];
346
- const normalized = Math.max(0, Math.min(1, (db + rangeDb + gainDb) / rangeDb));
347
- const colorIdx = Math.floor(normalized * 255);
348
- const pixelIdx = (y * canvasWidth + x) * 4;
349
- pixels[pixelIdx] = colorLUT[colorIdx * 3];
350
- pixels[pixelIdx + 1] = colorLUT[colorIdx * 3 + 1];
351
- pixels[pixelIdx + 2] = colorLUT[colorIdx * 3 + 2];
352
- pixels[pixelIdx + 3] = 255;
353
- }
354
- }
355
- if (devicePixelRatio === 1) {
356
- ctx.putImageData(imgData, 0, 0);
357
- } else {
358
- const tmpCanvas = new OffscreenCanvas(canvasWidth, canvasHeight);
359
- const tmpCtx = tmpCanvas.getContext("2d");
360
- if (!tmpCtx) {
361
- console.warn(
362
- `[spectrogram-worker] getContext('2d') failed for DPR scaling of "${canvasIds[chunkIdx]}"`
363
- );
364
- continue;
365
- }
366
- tmpCtx.putImageData(imgData, 0, 0);
367
- ctx.imageSmoothingEnabled = false;
368
- ctx.drawImage(tmpCanvas, 0, 0, offscreen.width, offscreen.height);
369
- }
370
- if (!globalPixelOffsets) accumulatedOffset += canvasWidth;
371
- }
372
- }
373
- async function handleComputeFFT(msg) {
374
- const {
375
- id,
376
- generation,
377
- clipId,
378
- config,
379
- sampleRate: msgSampleRate,
380
- offsetSamples,
381
- durationSamples,
382
- mono,
383
- sampleRange,
384
- channelFilter
385
- } = msg;
386
- const registered = audioDataRegistry.get(clipId);
387
- const channelDataArrays = registered && msg.channelDataArrays.length === 0 ? registered.channelDataArrays : msg.channelDataArrays;
388
- const sampleRate = registered && msg.channelDataArrays.length === 0 ? registered.sampleRate : msgSampleRate;
389
- const fftSize = config.fftSize ?? 2048;
390
- const zeroPaddingFactor = config.zeroPaddingFactor ?? 2;
391
- const hopSize = config.hopSize ?? Math.floor(fftSize / 4);
392
- const windowFunction = config.windowFunction ?? "hann";
393
- const effectiveOffset = sampleRange ? sampleRange.start : offsetSamples;
394
- const effectiveDuration = sampleRange ? sampleRange.end - sampleRange.start : durationSamples;
395
- const cacheKey = generateCacheKey({
396
- clipId,
397
- channelIndex: 0,
398
- offsetSamples: effectiveOffset,
399
- durationSamples: effectiveDuration,
400
- sampleRate,
401
- compute: { fftSize, zeroPaddingFactor, hopSize, windowFunction, alpha: config.alpha },
402
- mono
403
- });
404
- if (!fftCache.has(cacheKey)) {
405
- evictLRUCacheEntries(cacheKey);
406
- const spectrograms = [];
407
- if (mono || channelDataArrays.length === 1) {
408
- const result = await computeMonoFromChannels(
409
- channelDataArrays,
410
- config,
411
- sampleRate,
412
- effectiveOffset,
413
- effectiveDuration,
414
- generation
415
- );
416
- if (result === null) {
417
- const response2 = { id, type: "aborted" };
418
- self.postMessage(response2);
419
- return;
420
- }
421
- spectrograms.push(result);
422
- } else if (channelFilter !== void 0) {
423
- const channelData = channelDataArrays[channelFilter];
424
- if (!channelData) {
425
- const response2 = {
426
- id,
427
- type: "error",
428
- error: `channelFilter ${channelFilter} out of range (${channelDataArrays.length} channels)`
429
- };
430
- self.postMessage(response2);
431
- return;
432
- }
433
- const result = await computeFromChannelData(
434
- channelData,
435
- config,
436
- sampleRate,
437
- effectiveOffset,
438
- effectiveDuration,
439
- generation
440
- );
441
- if (result === null) {
442
- const response2 = { id, type: "aborted" };
443
- self.postMessage(response2);
444
- return;
445
- }
446
- spectrograms.push(result);
447
- } else {
448
- for (const channelData of channelDataArrays) {
449
- const result = await computeFromChannelData(
450
- channelData,
451
- config,
452
- sampleRate,
453
- effectiveOffset,
454
- effectiveDuration,
455
- generation
456
- );
457
- if (result === null) {
458
- const response2 = { id, type: "aborted" };
459
- self.postMessage(response2);
460
- return;
461
- }
462
- spectrograms.push(result);
463
- }
464
- }
465
- fftCache.set(cacheKey, { spectrograms, sampleOffset: effectiveOffset });
466
- } else {
467
- touchCacheEntry(cacheKey);
468
- }
469
- const response = { id, type: "cache-key", cacheKey };
470
- self.postMessage(response);
471
- }
472
- self.onmessage = (e) => {
473
- const msg = e.data;
474
- if (msg.type === "register-canvas") {
475
- try {
476
- canvasRegistry.set(msg.canvasId, msg.canvas);
477
- } catch (err) {
478
- console.warn("[spectrogram-worker] register-canvas failed:", err);
479
- }
480
- return;
481
- }
482
- if (msg.type === "unregister-canvas") {
483
- try {
484
- canvasRegistry.delete(msg.canvasId);
485
- } catch (err) {
486
- console.warn("[spectrogram-worker] unregister-canvas failed:", err);
487
- }
488
- return;
489
- }
490
- if (msg.type === "register-audio-data") {
491
- try {
492
- audioDataRegistry.set(msg.clipId, {
493
- channelDataArrays: msg.channelDataArrays,
494
- sampleRate: msg.sampleRate
495
- });
496
- } catch (err) {
497
- console.warn("[spectrogram-worker] register-audio-data failed:", err);
498
- }
499
- return;
500
- }
501
- if (msg.type === "unregister-audio-data") {
502
- try {
503
- audioDataRegistry.delete(msg.clipId);
504
- const prefix = `${msg.clipId}:`;
505
- for (const key of fftCache.keys()) {
506
- if (key.startsWith(prefix)) {
507
- fftCache.delete(key);
508
- }
509
- }
510
- } catch (err) {
511
- console.warn("[spectrogram-worker] unregister-audio-data failed:", err);
512
- }
513
- return;
514
- }
515
- if (msg.type === "abort-generation") {
516
- latestGeneration = Math.max(latestGeneration, msg.generation);
517
- return;
518
- }
519
- if (msg.type === "compute-fft") {
520
- const { id, generation } = msg;
521
- if (isGenerationStale(generation)) {
522
- const response = { id, type: "aborted" };
523
- self.postMessage(response);
524
- return;
525
- }
526
- handleComputeFFT(msg).catch((err) => {
527
- const errorMsg = err instanceof Error ? `${err.message}
528
- ${err.stack}` : String(err);
529
- const response = { id, type: "error", error: errorMsg };
530
- self.postMessage(response);
531
- });
532
- return;
533
- }
534
- if (msg.type === "render-chunks") {
535
- const { id, generation } = msg;
536
- if (isGenerationStale(generation)) {
537
- const response = { id, type: "aborted" };
538
- self.postMessage(response);
539
- return;
540
- }
541
- try {
542
- const {
543
- cacheKey,
544
- canvasIds,
545
- canvasWidths,
546
- globalPixelOffsets,
547
- canvasHeight,
548
- devicePixelRatio,
549
- samplesPerPixel,
550
- colorLUT,
551
- frequencyScale,
552
- minFrequency,
553
- maxFrequency,
554
- gainDb,
555
- rangeDb,
556
- channelIndex
557
- } = msg;
558
- const cacheEntry = fftCache.get(cacheKey);
559
- if (!cacheEntry) {
560
- const response2 = {
561
- id,
562
- type: "error",
563
- error: `cache-miss: key "${cacheKey}" not found (cache has ${fftCache.size} entries)`
564
- };
565
- self.postMessage(response2);
566
- return;
567
- }
568
- if (channelIndex >= cacheEntry.spectrograms.length) {
569
- const response2 = {
570
- id,
571
- type: "error",
572
- error: `cache-miss: channelIndex ${channelIndex} out of range (${cacheEntry.spectrograms.length} channels cached)`
573
- };
574
- self.postMessage(response2);
575
- return;
576
- }
577
- const scaleFn = getFrequencyScale(frequencyScale ?? "mel");
578
- const isNonLinear = frequencyScale !== "linear";
579
- renderSpectrogramToCanvas(
580
- cacheEntry.spectrograms[channelIndex],
581
- canvasIds,
582
- canvasWidths,
583
- canvasHeight,
584
- devicePixelRatio,
585
- samplesPerPixel,
586
- colorLUT,
587
- scaleFn,
588
- minFrequency,
589
- maxFrequency,
590
- isNonLinear,
591
- globalPixelOffsets,
592
- gainDb,
593
- rangeDb,
594
- cacheEntry.sampleOffset
595
- );
596
- const response = { id, type: "done" };
597
- self.postMessage(response);
598
- } catch (err) {
599
- const response = { id, type: "error", error: String(err) };
600
- self.postMessage(response);
601
- }
602
- return;
603
- }
604
- console.warn(`[spectrogram-worker] Unknown message type: ${msg.type}`);
605
- };
606
- //# sourceMappingURL=spectrogram.worker.mjs.map