@waveform-playlist/spectrogram 9.5.0 → 9.5.2

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/index.mjs CHANGED
@@ -3776,6 +3776,12 @@ var SpectrogramSettingsModal = ({
3776
3776
  };
3777
3777
 
3778
3778
  // src/worker/createSpectrogramWorker.ts
3779
+ var SpectrogramAbortError = class extends Error {
3780
+ constructor() {
3781
+ super("aborted");
3782
+ this.name = "SpectrogramAbortError";
3783
+ }
3784
+ };
3779
3785
  function addPending(map, id, resolve, reject) {
3780
3786
  map.set(id, { resolve, reject });
3781
3787
  }
@@ -3793,15 +3799,15 @@ function createSpectrogramWorker(worker) {
3793
3799
  case "error":
3794
3800
  entry.reject(new Error(msg.error));
3795
3801
  break;
3802
+ case "aborted":
3803
+ entry.reject(new SpectrogramAbortError());
3804
+ break;
3796
3805
  case "cache-key":
3797
3806
  entry.resolve({ cacheKey: msg.cacheKey });
3798
3807
  break;
3799
3808
  case "done":
3800
3809
  entry.resolve(void 0);
3801
3810
  break;
3802
- case "spectrograms":
3803
- entry.resolve(msg.spectrograms);
3804
- break;
3805
3811
  }
3806
3812
  } else if (msg.id) {
3807
3813
  console.warn(`[spectrogram] Received response for unknown message ID: ${msg.id}`);
@@ -3815,28 +3821,7 @@ function createSpectrogramWorker(worker) {
3815
3821
  pending.clear();
3816
3822
  };
3817
3823
  return {
3818
- compute(params) {
3819
- if (terminated) return Promise.reject(new Error("Worker terminated"));
3820
- const id = String(++idCounter);
3821
- return new Promise((resolve, reject) => {
3822
- addPending(pending, id, resolve, reject);
3823
- const transferableArrays = params.channelDataArrays.map((arr) => arr.slice());
3824
- const transferables = transferableArrays.map((arr) => arr.buffer);
3825
- worker.postMessage(
3826
- {
3827
- id,
3828
- channelDataArrays: transferableArrays,
3829
- config: params.config,
3830
- sampleRate: params.sampleRate,
3831
- offsetSamples: params.offsetSamples,
3832
- durationSamples: params.durationSamples,
3833
- mono: params.mono
3834
- },
3835
- transferables
3836
- );
3837
- });
3838
- },
3839
- computeFFT(params) {
3824
+ computeFFT(params, generation = 0) {
3840
3825
  if (terminated) return Promise.reject(new Error("Worker terminated"));
3841
3826
  const id = String(++idCounter);
3842
3827
  return new Promise((resolve, reject) => {
@@ -3848,6 +3833,7 @@ function createSpectrogramWorker(worker) {
3848
3833
  {
3849
3834
  type: "compute-fft",
3850
3835
  id,
3836
+ generation,
3851
3837
  clipId: params.clipId,
3852
3838
  channelDataArrays: transferableArrays,
3853
3839
  config: params.config,
@@ -3855,13 +3841,14 @@ function createSpectrogramWorker(worker) {
3855
3841
  offsetSamples: params.offsetSamples,
3856
3842
  durationSamples: params.durationSamples,
3857
3843
  mono: params.mono,
3858
- ...params.sampleRange ? { sampleRange: params.sampleRange } : {}
3844
+ ...params.sampleRange ? { sampleRange: params.sampleRange } : {},
3845
+ ...params.channelFilter !== void 0 ? { channelFilter: params.channelFilter } : {}
3859
3846
  },
3860
3847
  transferables
3861
3848
  );
3862
3849
  });
3863
3850
  },
3864
- renderChunks(params) {
3851
+ renderChunks(params, generation = 0) {
3865
3852
  if (terminated) return Promise.reject(new Error("Worker terminated"));
3866
3853
  const id = String(++idCounter);
3867
3854
  return new Promise((resolve, reject) => {
@@ -3869,6 +3856,7 @@ function createSpectrogramWorker(worker) {
3869
3856
  worker.postMessage({
3870
3857
  type: "render-chunks",
3871
3858
  id,
3859
+ generation,
3872
3860
  cacheKey: params.cacheKey,
3873
3861
  canvasIds: params.canvasIds,
3874
3862
  canvasWidths: params.canvasWidths,
@@ -3886,6 +3874,10 @@ function createSpectrogramWorker(worker) {
3886
3874
  });
3887
3875
  });
3888
3876
  },
3877
+ abortGeneration(generation) {
3878
+ if (terminated) return;
3879
+ worker.postMessage({ type: "abort-generation", generation });
3880
+ },
3889
3881
  registerCanvas(canvasId, canvas) {
3890
3882
  worker.postMessage({ type: "register-canvas", canvasId, canvas }, [canvas]);
3891
3883
  },
@@ -3905,29 +3897,6 @@ function createSpectrogramWorker(worker) {
3905
3897
  worker.postMessage({ type: "unregister-audio-data", clipId });
3906
3898
  registeredClipIds.delete(clipId);
3907
3899
  },
3908
- computeAndRender(params) {
3909
- if (terminated) return Promise.reject(new Error("Worker terminated"));
3910
- const id = String(++idCounter);
3911
- return new Promise((resolve, reject) => {
3912
- addPending(pending, id, resolve, reject);
3913
- const transferableArrays = params.channelDataArrays.map((arr) => arr.slice());
3914
- const transferables = transferableArrays.map((arr) => arr.buffer);
3915
- worker.postMessage(
3916
- {
3917
- type: "compute-render",
3918
- id,
3919
- channelDataArrays: transferableArrays,
3920
- config: params.config,
3921
- sampleRate: params.sampleRate,
3922
- offsetSamples: params.offsetSamples,
3923
- durationSamples: params.durationSamples,
3924
- mono: params.mono,
3925
- render: params.render
3926
- },
3927
- transferables
3928
- );
3929
- });
3930
- },
3931
3900
  terminate() {
3932
3901
  terminated = true;
3933
3902
  worker.terminate();
@@ -3939,6 +3908,76 @@ function createSpectrogramWorker(worker) {
3939
3908
  };
3940
3909
  }
3941
3910
 
3911
+ // src/worker/createSpectrogramWorkerPool.ts
3912
+ function parseChannelFromCanvasId(canvasId) {
3913
+ const match = canvasId.match(/-ch(\d+)-/);
3914
+ return match ? parseInt(match[1], 10) : 0;
3915
+ }
3916
+ function defaultPoolSize() {
3917
+ return 2;
3918
+ }
3919
+ function createSpectrogramWorkerPool(createWorker, poolSize = defaultPoolSize()) {
3920
+ const workers = [];
3921
+ try {
3922
+ for (let i = 0; i < poolSize; i++) {
3923
+ workers.push(createSpectrogramWorker(createWorker()));
3924
+ }
3925
+ } catch (err) {
3926
+ for (const w of workers) {
3927
+ w.terminate();
3928
+ }
3929
+ throw err;
3930
+ }
3931
+ function getWorkerForChannel(channelIndex) {
3932
+ return workers[channelIndex % workers.length];
3933
+ }
3934
+ return {
3935
+ computeFFT(params, generation = 0) {
3936
+ if (params.mono) {
3937
+ return workers[0].computeFFT(params, generation);
3938
+ }
3939
+ const channelCount = params.channelDataArrays.length;
3940
+ const activeWorkers = workers.slice(0, channelCount);
3941
+ const promises = activeWorkers.map(
3942
+ (w, i) => w.computeFFT({ ...params, channelFilter: i }, generation)
3943
+ );
3944
+ return Promise.all(promises).then((results) => results[0]);
3945
+ },
3946
+ renderChunks(params, generation = 0) {
3947
+ const worker = getWorkerForChannel(params.channelIndex);
3948
+ return worker.renderChunks({ ...params, channelIndex: 0 }, generation);
3949
+ },
3950
+ abortGeneration(generation) {
3951
+ for (const w of workers) {
3952
+ w.abortGeneration(generation);
3953
+ }
3954
+ },
3955
+ registerCanvas(canvasId, canvas) {
3956
+ const ch = parseChannelFromCanvasId(canvasId);
3957
+ getWorkerForChannel(ch).registerCanvas(canvasId, canvas);
3958
+ },
3959
+ unregisterCanvas(canvasId) {
3960
+ const ch = parseChannelFromCanvasId(canvasId);
3961
+ getWorkerForChannel(ch).unregisterCanvas(canvasId);
3962
+ },
3963
+ registerAudioData(clipId, channelDataArrays, sampleRate) {
3964
+ for (const w of workers) {
3965
+ w.registerAudioData(clipId, channelDataArrays, sampleRate);
3966
+ }
3967
+ },
3968
+ unregisterAudioData(clipId) {
3969
+ for (const w of workers) {
3970
+ w.unregisterAudioData(clipId);
3971
+ }
3972
+ },
3973
+ terminate() {
3974
+ for (const w of workers) {
3975
+ w.terminate();
3976
+ }
3977
+ }
3978
+ };
3979
+ }
3980
+
3942
3981
  // src/SpectrogramProvider.tsx
3943
3982
  import { useState as useState2, useEffect as useEffect2, useRef as useRef2, useCallback, useMemo } from "react";
3944
3983
  import {
@@ -3960,13 +3999,11 @@ function extractChunkNumber(canvasId) {
3960
3999
  var SpectrogramProvider = ({
3961
4000
  config: spectrogramConfig,
3962
4001
  colorMap: spectrogramColorMap,
4002
+ workerPoolSize,
3963
4003
  children
3964
4004
  }) => {
3965
- const { tracks, waveHeight, samplesPerPixel, isReady, mono, controls } = usePlaylistData();
4005
+ const { tracks, waveHeight, samplesPerPixel, isReady, mono } = usePlaylistData();
3966
4006
  const { scrollContainerRef } = usePlaylistControls();
3967
- const [spectrogramDataMap, setSpectrogramDataMap] = useState2(
3968
- /* @__PURE__ */ new Map()
3969
- );
3970
4007
  const [trackSpectrogramOverrides, setTrackSpectrogramOverrides] = useState2(/* @__PURE__ */ new Map());
3971
4008
  const spectrogramCanvasRegistryRef = useRef2(/* @__PURE__ */ new Map());
3972
4009
  const [spectrogramCanvasVersion, setSpectrogramCanvasVersion] = useState2(0);
@@ -3976,7 +4013,7 @@ var SpectrogramProvider = ({
3976
4013
  const spectrogramGenerationRef = useRef2(0);
3977
4014
  const prevCanvasVersionRef = useRef2(0);
3978
4015
  const [spectrogramWorkerReady, setSpectrogramWorkerReady] = useState2(false);
3979
- const clipCacheKeysRef = useRef2(/* @__PURE__ */ new Map());
4016
+ const renderedClipIdsRef = useRef2(/* @__PURE__ */ new Set());
3980
4017
  const backgroundRenderAbortRef = useRef2(null);
3981
4018
  const registeredAudioClipIdsRef = useRef2(/* @__PURE__ */ new Set());
3982
4019
  useEffect2(() => {
@@ -3990,15 +4027,19 @@ var SpectrogramProvider = ({
3990
4027
  let workerApi = spectrogramWorkerRef.current;
3991
4028
  if (!workerApi) {
3992
4029
  try {
3993
- const rawWorker = new Worker(
3994
- new URL("@waveform-playlist/spectrogram/worker/spectrogram.worker", import.meta.url),
3995
- { type: "module" }
4030
+ workerApi = createSpectrogramWorkerPool(
4031
+ () => new Worker(
4032
+ new URL("@waveform-playlist/spectrogram/worker/spectrogram.worker", import.meta.url),
4033
+ { type: "module" }
4034
+ ),
4035
+ workerPoolSize
3996
4036
  );
3997
- workerApi = createSpectrogramWorker(rawWorker);
3998
4037
  spectrogramWorkerRef.current = workerApi;
3999
4038
  setSpectrogramWorkerReady(true);
4000
- } catch {
4001
- console.warn("Spectrogram Web Worker unavailable for pre-transfer");
4039
+ } catch (err) {
4040
+ console.warn(
4041
+ `[waveform-playlist] Spectrogram Web Worker unavailable for pre-transfer: ${err instanceof Error ? err.message : String(err)}`
4042
+ );
4002
4043
  return;
4003
4044
  }
4004
4045
  }
@@ -4070,42 +4111,30 @@ var SpectrogramProvider = ({
4070
4111
  prevSpectrogramConfigRef.current = currentKeys;
4071
4112
  prevSpectrogramFFTKeyRef.current = currentFFTKeys;
4072
4113
  }
4073
- if (configChanged) {
4074
- setSpectrogramDataMap((prevMap) => {
4075
- const activeClipIds = /* @__PURE__ */ new Set();
4076
- for (const track of tracks) {
4077
- const mode = trackSpectrogramOverrides.get(track.id)?.renderMode ?? track.renderMode ?? "waveform";
4078
- if (mode === "spectrogram" || mode === "both") {
4079
- for (const clip of track.clips) {
4080
- activeClipIds.add(clip.id);
4081
- }
4082
- }
4083
- }
4084
- const newMap = new Map(prevMap);
4085
- for (const clipId of newMap.keys()) {
4086
- if (!activeClipIds.has(clipId)) {
4087
- newMap.delete(clipId);
4088
- }
4089
- }
4090
- return newMap;
4091
- });
4092
- }
4093
4114
  if (backgroundRenderAbortRef.current) {
4094
4115
  backgroundRenderAbortRef.current.aborted = true;
4095
4116
  }
4096
4117
  const generation = ++spectrogramGenerationRef.current;
4118
+ if (spectrogramWorkerRef.current) {
4119
+ spectrogramWorkerRef.current.abortGeneration(generation);
4120
+ }
4097
4121
  let workerApi = spectrogramWorkerRef.current;
4098
4122
  if (!workerApi) {
4099
4123
  try {
4100
- const rawWorker = new Worker(
4101
- new URL("@waveform-playlist/spectrogram/worker/spectrogram.worker", import.meta.url),
4102
- { type: "module" }
4124
+ workerApi = createSpectrogramWorkerPool(
4125
+ () => new Worker(
4126
+ new URL("@waveform-playlist/spectrogram/worker/spectrogram.worker", import.meta.url),
4127
+ { type: "module" }
4128
+ ),
4129
+ workerPoolSize
4103
4130
  );
4104
- workerApi = createSpectrogramWorker(rawWorker);
4105
4131
  spectrogramWorkerRef.current = workerApi;
4106
4132
  setSpectrogramWorkerReady(true);
4107
- } catch {
4108
- console.warn("Spectrogram Web Worker unavailable, falling back to synchronous computation");
4133
+ } catch (err) {
4134
+ console.error(
4135
+ `[waveform-playlist] Spectrogram Web Worker required but unavailable: ${err instanceof Error ? err.message : String(err)}`
4136
+ );
4137
+ return;
4109
4138
  }
4110
4139
  }
4111
4140
  const clipsNeedingFFT = [];
@@ -4122,11 +4151,19 @@ var SpectrogramProvider = ({
4122
4151
  for (const clip of track.clips) {
4123
4152
  if (!clip.audioBuffer) continue;
4124
4153
  const monoFlag = mono || clip.audioBuffer.numberOfChannels === 1;
4125
- if (!trackFFTChanged && !hasRegisteredCanvases && clipCacheKeysRef.current.has(clip.id)) {
4154
+ if (!trackFFTChanged && !hasRegisteredCanvases && renderedClipIdsRef.current.has(clip.id)) {
4155
+ const channelDataArrays2 = [];
4156
+ for (let ch = 0; ch < clip.audioBuffer.numberOfChannels; ch++) {
4157
+ channelDataArrays2.push(clip.audioBuffer.getChannelData(ch));
4158
+ }
4126
4159
  clipsNeedingDisplayOnly.push({
4127
4160
  clipId: clip.id,
4128
4161
  trackIndex: i,
4162
+ channelDataArrays: channelDataArrays2,
4129
4163
  config: cfg,
4164
+ sampleRate: clip.audioBuffer.sampleRate,
4165
+ offsetSamples: clip.offsetSamples,
4166
+ durationSamples: clip.durationSamples,
4130
4167
  clipStartSample: clip.startSample,
4131
4168
  monoFlag,
4132
4169
  colorMap: cm,
@@ -4153,68 +4190,38 @@ var SpectrogramProvider = ({
4153
4190
  }
4154
4191
  });
4155
4192
  if (clipsNeedingFFT.length === 0 && clipsNeedingDisplayOnly.length === 0) return;
4156
- if (!workerApi) {
4157
- try {
4158
- setSpectrogramDataMap((prevMap) => {
4159
- const newMap = new Map(prevMap);
4160
- for (const item of clipsNeedingFFT) {
4161
- const clip = tracks.flatMap((t) => t.clips).find((c) => c.id === item.clipId);
4162
- if (!clip?.audioBuffer) continue;
4163
- const channelSpectrograms = [];
4164
- if (item.monoFlag) {
4165
- channelSpectrograms.push(
4166
- computeSpectrogramMono(
4167
- clip.audioBuffer,
4168
- item.config,
4169
- item.offsetSamples,
4170
- item.durationSamples
4171
- )
4172
- );
4173
- } else {
4174
- for (let ch = 0; ch < clip.audioBuffer.numberOfChannels; ch++) {
4175
- channelSpectrograms.push(
4176
- computeSpectrogram(
4177
- clip.audioBuffer,
4178
- item.config,
4179
- item.offsetSamples,
4180
- item.durationSamples,
4181
- ch
4182
- )
4183
- );
4184
- }
4185
- }
4186
- newMap.set(item.clipId, channelSpectrograms);
4187
- }
4188
- return newMap;
4189
- });
4190
- } catch (err) {
4191
- console.error("[waveform-playlist] Synchronous spectrogram computation failed:", err);
4192
- }
4193
- return;
4194
- }
4195
4193
  const getVisibleChunkRange = (channelInfo, clipPixelOffset = 0) => {
4196
4194
  const container = scrollContainerRef.current;
4197
4195
  if (!container) {
4198
- return { visibleIndices: channelInfo.canvasWidths.map((_, i) => i), remainingIndices: [] };
4196
+ return {
4197
+ viewportIndices: channelInfo.canvasWidths.map((_, i) => i),
4198
+ bufferIndices: [],
4199
+ remainingIndices: []
4200
+ };
4199
4201
  }
4200
4202
  const scrollLeft = container.scrollLeft;
4201
4203
  const viewportWidth = container.clientWidth;
4202
- const controlWidth = controls.show ? controls.width : 0;
4203
- const visibleIndices = [];
4204
+ const buffer = viewportWidth * 1.5;
4205
+ const bufferStart = Math.max(0, scrollLeft - buffer);
4206
+ const bufferEnd = scrollLeft + viewportWidth + buffer;
4207
+ const viewportIndices = [];
4208
+ const bufferIndices = [];
4204
4209
  const remainingIndices = [];
4205
4210
  for (let i = 0; i < channelInfo.canvasWidths.length; i++) {
4206
4211
  const chunkNumber = extractChunkNumber(channelInfo.canvasIds[i]);
4207
- const chunkLeft = chunkNumber * MAX_CANVAS_WIDTH + controlWidth + clipPixelOffset;
4212
+ const chunkLeft = chunkNumber * MAX_CANVAS_WIDTH + clipPixelOffset;
4208
4213
  const chunkRight = chunkLeft + channelInfo.canvasWidths[i];
4209
4214
  if (chunkRight > scrollLeft && chunkLeft < scrollLeft + viewportWidth) {
4210
- visibleIndices.push(i);
4215
+ viewportIndices.push(i);
4216
+ } else if (chunkRight > bufferStart && chunkLeft < bufferEnd) {
4217
+ bufferIndices.push(i);
4211
4218
  } else {
4212
4219
  remainingIndices.push(i);
4213
4220
  }
4214
4221
  }
4215
- return { visibleIndices, remainingIndices };
4222
+ return { viewportIndices, bufferIndices, remainingIndices };
4216
4223
  };
4217
- const renderChunkSubset = async (api, cacheKey, channelInfo, indices, item, channelIndex) => {
4224
+ const renderChunkSubset = async (api, cacheKey, channelInfo, indices, item, channelIndex, gen) => {
4218
4225
  if (indices.length === 0) return;
4219
4226
  const canvasIds = indices.map((i) => channelInfo.canvasIds[i]);
4220
4227
  const canvasWidths = indices.map((i) => channelInfo.canvasWidths[i]);
@@ -4224,41 +4231,117 @@ var SpectrogramProvider = ({
4224
4231
  globalPixelOffsets.push(chunkNumber * MAX_CANVAS_WIDTH);
4225
4232
  }
4226
4233
  const colorLUT = getColorMap(item.colorMap);
4227
- await api.renderChunks({
4228
- cacheKey,
4229
- canvasIds,
4230
- canvasWidths,
4231
- globalPixelOffsets,
4232
- canvasHeight: waveHeight,
4233
- devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio : 1,
4234
- samplesPerPixel,
4235
- colorLUT,
4236
- frequencyScale: item.config.frequencyScale ?? "mel",
4237
- minFrequency: item.config.minFrequency ?? 0,
4238
- maxFrequency: item.config.maxFrequency ?? 0,
4239
- gainDb: item.config.gainDb ?? 20,
4240
- rangeDb: item.config.rangeDb ?? 80,
4241
- channelIndex
4242
- });
4234
+ await api.renderChunks(
4235
+ {
4236
+ cacheKey,
4237
+ canvasIds,
4238
+ canvasWidths,
4239
+ globalPixelOffsets,
4240
+ canvasHeight: waveHeight,
4241
+ devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio : 1,
4242
+ samplesPerPixel,
4243
+ colorLUT,
4244
+ frequencyScale: item.config.frequencyScale ?? "mel",
4245
+ minFrequency: item.config.minFrequency ?? 0,
4246
+ maxFrequency: item.config.maxFrequency ?? 0,
4247
+ gainDb: item.config.gainDb ?? 20,
4248
+ rangeDb: item.config.rangeDb ?? 80,
4249
+ channelIndex
4250
+ },
4251
+ gen
4252
+ );
4253
+ };
4254
+ const computeFFTForChunks = async (api, channelInfo, indices, item, gen) => {
4255
+ const chunkNumbers = indices.map((i) => extractChunkNumber(channelInfo.canvasIds[i]));
4256
+ const minChunk = Math.min(...chunkNumbers);
4257
+ const maxChunk = Math.max(...chunkNumbers);
4258
+ const maxChunkIdx = indices[chunkNumbers.indexOf(maxChunk)];
4259
+ const lastChunkWidth = channelInfo.canvasWidths[maxChunkIdx];
4260
+ const startPx = minChunk * MAX_CANVAS_WIDTH;
4261
+ const endPx = maxChunk * MAX_CANVAS_WIDTH + lastChunkWidth;
4262
+ const windowSize = item.config.fftSize ?? 2048;
4263
+ const rangeStartSample = item.offsetSamples + Math.floor(startPx * samplesPerPixel);
4264
+ const rangeEndSample = Math.min(
4265
+ item.offsetSamples + item.durationSamples,
4266
+ item.offsetSamples + Math.ceil(endPx * samplesPerPixel)
4267
+ );
4268
+ const paddedStart = Math.max(item.offsetSamples, rangeStartSample - windowSize);
4269
+ const paddedEnd = Math.min(
4270
+ item.offsetSamples + item.durationSamples,
4271
+ rangeEndSample + windowSize
4272
+ );
4273
+ const { cacheKey } = await api.computeFFT(
4274
+ {
4275
+ clipId: item.clipId,
4276
+ channelDataArrays: item.channelDataArrays,
4277
+ config: item.config,
4278
+ sampleRate: item.sampleRate,
4279
+ offsetSamples: item.offsetSamples,
4280
+ durationSamples: item.durationSamples,
4281
+ mono: item.monoFlag,
4282
+ sampleRange: { start: paddedStart, end: paddedEnd }
4283
+ },
4284
+ gen
4285
+ );
4286
+ return cacheKey;
4287
+ };
4288
+ const groupContiguousIndices = (channelInfo, indices) => {
4289
+ if (indices.length === 0) return [];
4290
+ const groups = [];
4291
+ let currentGroup = [indices[0]];
4292
+ let prevChunk = extractChunkNumber(channelInfo.canvasIds[indices[0]]);
4293
+ for (let i = 1; i < indices.length; i++) {
4294
+ const chunk = extractChunkNumber(channelInfo.canvasIds[indices[i]]);
4295
+ if (chunk === prevChunk + 1) {
4296
+ currentGroup.push(indices[i]);
4297
+ } else {
4298
+ groups.push(currentGroup);
4299
+ currentGroup = [indices[i]];
4300
+ }
4301
+ prevChunk = chunk;
4302
+ }
4303
+ groups.push(currentGroup);
4304
+ return groups;
4243
4305
  };
4244
4306
  const computeAsync = async () => {
4245
4307
  const abortToken = { aborted: false };
4246
4308
  backgroundRenderAbortRef.current = abortToken;
4247
- const renderBackgroundBatches = async (channelRanges, cacheKey, item) => {
4248
- const BATCH_SIZE = 4;
4249
- for (const { ch, channelInfo, remainingIndices } of channelRanges) {
4250
- for (let batchStart = 0; batchStart < remainingIndices.length; batchStart += BATCH_SIZE) {
4251
- if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4252
- const batch = remainingIndices.slice(batchStart, batchStart + BATCH_SIZE);
4253
- await new Promise((resolve) => {
4254
- if (typeof requestIdleCallback === "function") {
4255
- requestIdleCallback(() => resolve());
4256
- } else {
4257
- setTimeout(resolve, 0);
4258
- }
4309
+ const renderBackgroundBatches = async (channelRanges, item) => {
4310
+ const allGroups = [];
4311
+ if (channelRanges.length > 0) {
4312
+ const { channelInfo, remainingIndices } = channelRanges[0];
4313
+ const groups = groupContiguousIndices(channelInfo, remainingIndices);
4314
+ for (const group of groups) {
4315
+ allGroups.push({
4316
+ group,
4317
+ channelRangeEntries: channelRanges.map(({ ch, channelInfo: ci }) => ({
4318
+ ch,
4319
+ channelInfo: ci
4320
+ }))
4259
4321
  });
4322
+ }
4323
+ }
4324
+ for (const { group, channelRangeEntries } of allGroups) {
4325
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4326
+ await new Promise((resolve) => {
4327
+ if (typeof requestIdleCallback === "function") {
4328
+ requestIdleCallback(() => resolve());
4329
+ } else {
4330
+ setTimeout(resolve, 0);
4331
+ }
4332
+ });
4333
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4334
+ const { channelInfo: firstChannelInfo } = channelRangeEntries[0];
4335
+ const cacheKey = await computeFFTForChunks(
4336
+ workerApi,
4337
+ firstChannelInfo,
4338
+ group,
4339
+ item,
4340
+ generation
4341
+ );
4342
+ for (const { ch, channelInfo: ci } of channelRangeEntries) {
4260
4343
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4261
- await renderChunkSubset(workerApi, cacheKey, channelInfo, batch, item, ch);
4344
+ await renderChunkSubset(workerApi, cacheKey, ci, group, item, ch, generation);
4262
4345
  }
4263
4346
  }
4264
4347
  return false;
@@ -4270,117 +4353,76 @@ var SpectrogramProvider = ({
4270
4353
  if (clipCanvasInfo && clipCanvasInfo.size > 0) {
4271
4354
  const numChannels = item.monoFlag ? 1 : item.channelDataArrays.length;
4272
4355
  const clipPixelOffset = Math.floor(item.clipStartSample / samplesPerPixel);
4273
- const container = scrollContainerRef.current;
4274
- const windowSize = item.config.fftSize ?? 2048;
4275
- let visibleRange;
4276
- if (container) {
4277
- const scrollLeft = container.scrollLeft;
4278
- const viewportWidth = container.clientWidth;
4279
- const controlWidth = controls.show ? controls.width : 0;
4280
- const vpStartPx = Math.max(0, scrollLeft - controlWidth);
4281
- const vpEndPx = vpStartPx + viewportWidth;
4282
- const clipStartPx = clipPixelOffset;
4283
- const clipEndPx = clipStartPx + Math.ceil(item.durationSamples / samplesPerPixel);
4284
- const overlapStartPx = Math.max(vpStartPx, clipStartPx);
4285
- const overlapEndPx = Math.min(vpEndPx, clipEndPx);
4286
- if (overlapEndPx > overlapStartPx) {
4287
- const localStartPx = overlapStartPx - clipStartPx;
4288
- const localEndPx = overlapEndPx - clipStartPx;
4289
- const visStartSample = item.offsetSamples + Math.floor(localStartPx * samplesPerPixel);
4290
- const visEndSample = Math.min(
4291
- item.offsetSamples + item.durationSamples,
4292
- item.offsetSamples + Math.ceil(localEndPx * samplesPerPixel)
4293
- );
4294
- const paddedStart = Math.max(item.offsetSamples, visStartSample - windowSize);
4295
- const paddedEnd = Math.min(
4296
- item.offsetSamples + item.durationSamples,
4297
- visEndSample + windowSize
4298
- );
4299
- if (paddedEnd - paddedStart < item.durationSamples * 0.8) {
4300
- visibleRange = { start: paddedStart, end: paddedEnd };
4301
- }
4302
- }
4356
+ const channelRanges = [];
4357
+ for (let ch = 0; ch < numChannels; ch++) {
4358
+ const channelInfo = clipCanvasInfo.get(ch);
4359
+ if (!channelInfo) continue;
4360
+ const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4361
+ channelRanges.push({ ch, channelInfo, ...range });
4303
4362
  }
4304
- const fullClipAlreadyCached = clipCacheKeysRef.current.has(item.clipId);
4305
- if (visibleRange && !fullClipAlreadyCached) {
4306
- const { cacheKey: visibleCacheKey } = await workerApi.computeFFT({
4307
- clipId: item.clipId,
4308
- channelDataArrays: item.channelDataArrays,
4309
- config: item.config,
4310
- sampleRate: item.sampleRate,
4311
- offsetSamples: item.offsetSamples,
4312
- durationSamples: item.durationSamples,
4313
- mono: item.monoFlag,
4314
- sampleRange: visibleRange
4315
- });
4363
+ if (channelRanges.length > 0 && channelRanges[0].viewportIndices.length > 0) {
4364
+ const cacheKey = await computeFFTForChunks(
4365
+ workerApi,
4366
+ channelRanges[0].channelInfo,
4367
+ channelRanges[0].viewportIndices,
4368
+ item,
4369
+ generation
4370
+ );
4316
4371
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4317
- for (let ch = 0; ch < numChannels; ch++) {
4318
- const channelInfo = clipCanvasInfo.get(ch);
4319
- if (!channelInfo) continue;
4320
- const { visibleIndices } = getVisibleChunkRange(channelInfo, clipPixelOffset);
4372
+ for (const { ch, channelInfo, viewportIndices } of channelRanges) {
4321
4373
  await renderChunkSubset(
4322
4374
  workerApi,
4323
- visibleCacheKey,
4375
+ cacheKey,
4324
4376
  channelInfo,
4325
- visibleIndices,
4377
+ viewportIndices,
4326
4378
  item,
4327
- ch
4379
+ ch,
4380
+ generation
4328
4381
  );
4329
4382
  }
4330
- if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4331
4383
  }
4332
- const { cacheKey } = await workerApi.computeFFT({
4333
- clipId: item.clipId,
4334
- channelDataArrays: item.channelDataArrays,
4335
- config: item.config,
4336
- sampleRate: item.sampleRate,
4337
- offsetSamples: item.offsetSamples,
4338
- durationSamples: item.durationSamples,
4339
- mono: item.monoFlag
4340
- });
4341
4384
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4342
- clipCacheKeysRef.current.set(item.clipId, cacheKey);
4343
- const channelRanges = [];
4344
- for (let ch = 0; ch < numChannels; ch++) {
4345
- const channelInfo = clipCanvasInfo.get(ch);
4346
- if (!channelInfo) continue;
4347
- const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4348
- channelRanges.push({ ch, channelInfo, ...range });
4349
- await renderChunkSubset(
4350
- workerApi,
4351
- cacheKey,
4352
- channelInfo,
4353
- range.visibleIndices,
4354
- item,
4355
- ch
4385
+ if (channelRanges.length > 0 && channelRanges[0].bufferIndices.length > 0) {
4386
+ const bufferGroups = groupContiguousIndices(
4387
+ channelRanges[0].channelInfo,
4388
+ channelRanges[0].bufferIndices
4356
4389
  );
4390
+ for (const group of bufferGroups) {
4391
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4392
+ const cacheKey = await computeFFTForChunks(
4393
+ workerApi,
4394
+ channelRanges[0].channelInfo,
4395
+ group,
4396
+ item,
4397
+ generation
4398
+ );
4399
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4400
+ for (const { ch, channelInfo } of channelRanges) {
4401
+ await renderChunkSubset(
4402
+ workerApi,
4403
+ cacheKey,
4404
+ channelInfo,
4405
+ group,
4406
+ item,
4407
+ ch,
4408
+ generation
4409
+ );
4410
+ }
4411
+ }
4357
4412
  }
4413
+ renderedClipIdsRef.current.add(item.clipId);
4358
4414
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4359
- if (await renderBackgroundBatches(channelRanges, cacheKey, item)) return;
4360
- } else {
4361
- const spectrograms = await workerApi.compute({
4362
- channelDataArrays: item.channelDataArrays,
4363
- config: item.config,
4364
- sampleRate: item.sampleRate,
4365
- offsetSamples: item.offsetSamples,
4366
- durationSamples: item.durationSamples,
4367
- mono: item.monoFlag
4368
- });
4369
- if (spectrogramGenerationRef.current !== generation) return;
4370
- setSpectrogramDataMap((prevMap) => {
4371
- const newMap = new Map(prevMap);
4372
- newMap.set(item.clipId, spectrograms);
4373
- return newMap;
4374
- });
4415
+ if (await renderBackgroundBatches(channelRanges, item)) return;
4375
4416
  }
4376
4417
  } catch (err) {
4377
- console.warn("Spectrogram worker error for clip", item.clipId, err);
4418
+ if (err instanceof SpectrogramAbortError) return;
4419
+ console.warn(
4420
+ `[waveform-playlist] Spectrogram worker error for clip ${item.clipId}: ${err instanceof Error ? err.message : String(err)}`
4421
+ );
4378
4422
  }
4379
4423
  }
4380
4424
  for (const item of clipsNeedingDisplayOnly) {
4381
4425
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4382
- const cacheKey = clipCacheKeysRef.current.get(item.clipId);
4383
- if (!cacheKey) continue;
4384
4426
  const clipCanvasInfo = spectrogramCanvasRegistryRef.current.get(item.clipId);
4385
4427
  if (!clipCanvasInfo || clipCanvasInfo.size === 0) continue;
4386
4428
  try {
@@ -4389,22 +4431,73 @@ var SpectrogramProvider = ({
4389
4431
  for (let ch = 0; ch < item.numChannels; ch++) {
4390
4432
  const channelInfo = clipCanvasInfo.get(ch);
4391
4433
  if (!channelInfo) continue;
4392
- const { visibleIndices, remainingIndices } = getVisibleChunkRange(
4393
- channelInfo,
4394
- clipPixelOffset
4434
+ const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4435
+ channelRanges.push({ ch, channelInfo, ...range });
4436
+ }
4437
+ if (channelRanges.length > 0 && channelRanges[0].viewportIndices.length > 0) {
4438
+ const cacheKey = await computeFFTForChunks(
4439
+ workerApi,
4440
+ channelRanges[0].channelInfo,
4441
+ channelRanges[0].viewportIndices,
4442
+ item,
4443
+ generation
4444
+ );
4445
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4446
+ for (const { ch, channelInfo, viewportIndices } of channelRanges) {
4447
+ await renderChunkSubset(
4448
+ workerApi,
4449
+ cacheKey,
4450
+ channelInfo,
4451
+ viewportIndices,
4452
+ item,
4453
+ ch,
4454
+ generation
4455
+ );
4456
+ }
4457
+ }
4458
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4459
+ if (channelRanges.length > 0 && channelRanges[0].bufferIndices.length > 0) {
4460
+ const bufferGroups = groupContiguousIndices(
4461
+ channelRanges[0].channelInfo,
4462
+ channelRanges[0].bufferIndices
4395
4463
  );
4396
- channelRanges.push({ ch, channelInfo, remainingIndices });
4397
- await renderChunkSubset(workerApi, cacheKey, channelInfo, visibleIndices, item, ch);
4464
+ for (const group of bufferGroups) {
4465
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4466
+ const cacheKey = await computeFFTForChunks(
4467
+ workerApi,
4468
+ channelRanges[0].channelInfo,
4469
+ group,
4470
+ item,
4471
+ generation
4472
+ );
4473
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4474
+ for (const { ch, channelInfo } of channelRanges) {
4475
+ await renderChunkSubset(
4476
+ workerApi,
4477
+ cacheKey,
4478
+ channelInfo,
4479
+ group,
4480
+ item,
4481
+ ch,
4482
+ generation
4483
+ );
4484
+ }
4485
+ }
4398
4486
  }
4399
4487
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4400
- if (await renderBackgroundBatches(channelRanges, cacheKey, item)) return;
4488
+ if (await renderBackgroundBatches(channelRanges, item)) return;
4401
4489
  } catch (err) {
4402
- console.warn("Spectrogram display re-render error for clip", item.clipId, err);
4490
+ if (err instanceof SpectrogramAbortError) return;
4491
+ console.warn(
4492
+ `[waveform-playlist] Spectrogram display re-render error for clip ${item.clipId}: ${err instanceof Error ? err.message : String(err)}`
4493
+ );
4403
4494
  }
4404
4495
  }
4405
4496
  };
4406
4497
  computeAsync().catch((err) => {
4407
- console.error("[waveform-playlist] Spectrogram computation failed:", err);
4498
+ console.error(
4499
+ `[waveform-playlist] Spectrogram computation failed: ${err instanceof Error ? err.message : String(err)}`
4500
+ );
4408
4501
  });
4409
4502
  }, [
4410
4503
  tracks,
@@ -4415,7 +4508,6 @@ var SpectrogramProvider = ({
4415
4508
  waveHeight,
4416
4509
  samplesPerPixel,
4417
4510
  spectrogramCanvasVersion,
4418
- controls,
4419
4511
  scrollContainerRef
4420
4512
  ]);
4421
4513
  const setTrackRenderMode = useCallback((trackId, mode) => {
@@ -4475,7 +4567,6 @@ var SpectrogramProvider = ({
4475
4567
  );
4476
4568
  const value = useMemo(
4477
4569
  () => ({
4478
- spectrogramDataMap,
4479
4570
  trackSpectrogramOverrides,
4480
4571
  spectrogramWorkerApi: spectrogramWorkerReady ? spectrogramWorkerRef.current : null,
4481
4572
  spectrogramConfig,
@@ -4490,7 +4581,6 @@ var SpectrogramProvider = ({
4490
4581
  getFrequencyScale
4491
4582
  }),
4492
4583
  [
4493
- spectrogramDataMap,
4494
4584
  trackSpectrogramOverrides,
4495
4585
  spectrogramWorkerReady,
4496
4586
  spectrogramConfig,
@@ -4505,12 +4595,14 @@ var SpectrogramProvider = ({
4505
4595
  return /* @__PURE__ */ jsx3(SpectrogramIntegrationProvider, { value, children });
4506
4596
  };
4507
4597
  export {
4598
+ SpectrogramAbortError,
4508
4599
  SpectrogramMenuItems,
4509
4600
  SpectrogramProvider,
4510
4601
  SpectrogramSettingsModal,
4511
4602
  computeSpectrogram,
4512
4603
  computeSpectrogramMono,
4513
4604
  createSpectrogramWorker,
4605
+ createSpectrogramWorkerPool,
4514
4606
  getColorMap,
4515
4607
  getFrequencyScale
4516
4608
  };