@waveform-playlist/spectrogram 9.4.1 → 9.5.1

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,28 @@ 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(`[waveform-playlist] Spectrogram Web Worker required but unavailable: ${err instanceof Error ? err.message : String(err)}`);
4135
+ return;
4109
4136
  }
4110
4137
  }
4111
4138
  const clipsNeedingFFT = [];
@@ -4122,11 +4149,19 @@ var SpectrogramProvider = ({
4122
4149
  for (const clip of track.clips) {
4123
4150
  if (!clip.audioBuffer) continue;
4124
4151
  const monoFlag = mono || clip.audioBuffer.numberOfChannels === 1;
4125
- if (!trackFFTChanged && !hasRegisteredCanvases && clipCacheKeysRef.current.has(clip.id)) {
4152
+ if (!trackFFTChanged && !hasRegisteredCanvases && renderedClipIdsRef.current.has(clip.id)) {
4153
+ const channelDataArrays2 = [];
4154
+ for (let ch = 0; ch < clip.audioBuffer.numberOfChannels; ch++) {
4155
+ channelDataArrays2.push(clip.audioBuffer.getChannelData(ch));
4156
+ }
4126
4157
  clipsNeedingDisplayOnly.push({
4127
4158
  clipId: clip.id,
4128
4159
  trackIndex: i,
4160
+ channelDataArrays: channelDataArrays2,
4129
4161
  config: cfg,
4162
+ sampleRate: clip.audioBuffer.sampleRate,
4163
+ offsetSamples: clip.offsetSamples,
4164
+ durationSamples: clip.durationSamples,
4130
4165
  clipStartSample: clip.startSample,
4131
4166
  monoFlag,
4132
4167
  colorMap: cm,
@@ -4153,68 +4188,38 @@ var SpectrogramProvider = ({
4153
4188
  }
4154
4189
  });
4155
4190
  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
4191
  const getVisibleChunkRange = (channelInfo, clipPixelOffset = 0) => {
4196
4192
  const container = scrollContainerRef.current;
4197
4193
  if (!container) {
4198
- return { visibleIndices: channelInfo.canvasWidths.map((_, i) => i), remainingIndices: [] };
4194
+ return {
4195
+ viewportIndices: channelInfo.canvasWidths.map((_, i) => i),
4196
+ bufferIndices: [],
4197
+ remainingIndices: []
4198
+ };
4199
4199
  }
4200
4200
  const scrollLeft = container.scrollLeft;
4201
4201
  const viewportWidth = container.clientWidth;
4202
- const controlWidth = controls.show ? controls.width : 0;
4203
- const visibleIndices = [];
4202
+ const buffer = viewportWidth * 1.5;
4203
+ const bufferStart = Math.max(0, scrollLeft - buffer);
4204
+ const bufferEnd = scrollLeft + viewportWidth + buffer;
4205
+ const viewportIndices = [];
4206
+ const bufferIndices = [];
4204
4207
  const remainingIndices = [];
4205
4208
  for (let i = 0; i < channelInfo.canvasWidths.length; i++) {
4206
4209
  const chunkNumber = extractChunkNumber(channelInfo.canvasIds[i]);
4207
- const chunkLeft = chunkNumber * MAX_CANVAS_WIDTH + controlWidth + clipPixelOffset;
4210
+ const chunkLeft = chunkNumber * MAX_CANVAS_WIDTH + clipPixelOffset;
4208
4211
  const chunkRight = chunkLeft + channelInfo.canvasWidths[i];
4209
4212
  if (chunkRight > scrollLeft && chunkLeft < scrollLeft + viewportWidth) {
4210
- visibleIndices.push(i);
4213
+ viewportIndices.push(i);
4214
+ } else if (chunkRight > bufferStart && chunkLeft < bufferEnd) {
4215
+ bufferIndices.push(i);
4211
4216
  } else {
4212
4217
  remainingIndices.push(i);
4213
4218
  }
4214
4219
  }
4215
- return { visibleIndices, remainingIndices };
4220
+ return { viewportIndices, bufferIndices, remainingIndices };
4216
4221
  };
4217
- const renderChunkSubset = async (api, cacheKey, channelInfo, indices, item, channelIndex) => {
4222
+ const renderChunkSubset = async (api, cacheKey, channelInfo, indices, item, channelIndex, gen) => {
4218
4223
  if (indices.length === 0) return;
4219
4224
  const canvasIds = indices.map((i) => channelInfo.canvasIds[i]);
4220
4225
  const canvasWidths = indices.map((i) => channelInfo.canvasWidths[i]);
@@ -4224,41 +4229,117 @@ var SpectrogramProvider = ({
4224
4229
  globalPixelOffsets.push(chunkNumber * MAX_CANVAS_WIDTH);
4225
4230
  }
4226
4231
  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
- });
4232
+ await api.renderChunks(
4233
+ {
4234
+ cacheKey,
4235
+ canvasIds,
4236
+ canvasWidths,
4237
+ globalPixelOffsets,
4238
+ canvasHeight: waveHeight,
4239
+ devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio : 1,
4240
+ samplesPerPixel,
4241
+ colorLUT,
4242
+ frequencyScale: item.config.frequencyScale ?? "mel",
4243
+ minFrequency: item.config.minFrequency ?? 0,
4244
+ maxFrequency: item.config.maxFrequency ?? 0,
4245
+ gainDb: item.config.gainDb ?? 20,
4246
+ rangeDb: item.config.rangeDb ?? 80,
4247
+ channelIndex
4248
+ },
4249
+ gen
4250
+ );
4251
+ };
4252
+ const computeFFTForChunks = async (api, channelInfo, indices, item, gen) => {
4253
+ const chunkNumbers = indices.map((i) => extractChunkNumber(channelInfo.canvasIds[i]));
4254
+ const minChunk = Math.min(...chunkNumbers);
4255
+ const maxChunk = Math.max(...chunkNumbers);
4256
+ const maxChunkIdx = indices[chunkNumbers.indexOf(maxChunk)];
4257
+ const lastChunkWidth = channelInfo.canvasWidths[maxChunkIdx];
4258
+ const startPx = minChunk * MAX_CANVAS_WIDTH;
4259
+ const endPx = maxChunk * MAX_CANVAS_WIDTH + lastChunkWidth;
4260
+ const windowSize = item.config.fftSize ?? 2048;
4261
+ const rangeStartSample = item.offsetSamples + Math.floor(startPx * samplesPerPixel);
4262
+ const rangeEndSample = Math.min(
4263
+ item.offsetSamples + item.durationSamples,
4264
+ item.offsetSamples + Math.ceil(endPx * samplesPerPixel)
4265
+ );
4266
+ const paddedStart = Math.max(item.offsetSamples, rangeStartSample - windowSize);
4267
+ const paddedEnd = Math.min(
4268
+ item.offsetSamples + item.durationSamples,
4269
+ rangeEndSample + windowSize
4270
+ );
4271
+ const { cacheKey } = await api.computeFFT(
4272
+ {
4273
+ clipId: item.clipId,
4274
+ channelDataArrays: item.channelDataArrays,
4275
+ config: item.config,
4276
+ sampleRate: item.sampleRate,
4277
+ offsetSamples: item.offsetSamples,
4278
+ durationSamples: item.durationSamples,
4279
+ mono: item.monoFlag,
4280
+ sampleRange: { start: paddedStart, end: paddedEnd }
4281
+ },
4282
+ gen
4283
+ );
4284
+ return cacheKey;
4285
+ };
4286
+ const groupContiguousIndices = (channelInfo, indices) => {
4287
+ if (indices.length === 0) return [];
4288
+ const groups = [];
4289
+ let currentGroup = [indices[0]];
4290
+ let prevChunk = extractChunkNumber(channelInfo.canvasIds[indices[0]]);
4291
+ for (let i = 1; i < indices.length; i++) {
4292
+ const chunk = extractChunkNumber(channelInfo.canvasIds[indices[i]]);
4293
+ if (chunk === prevChunk + 1) {
4294
+ currentGroup.push(indices[i]);
4295
+ } else {
4296
+ groups.push(currentGroup);
4297
+ currentGroup = [indices[i]];
4298
+ }
4299
+ prevChunk = chunk;
4300
+ }
4301
+ groups.push(currentGroup);
4302
+ return groups;
4243
4303
  };
4244
4304
  const computeAsync = async () => {
4245
4305
  const abortToken = { aborted: false };
4246
4306
  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
- }
4307
+ const renderBackgroundBatches = async (channelRanges, item) => {
4308
+ const allGroups = [];
4309
+ if (channelRanges.length > 0) {
4310
+ const { channelInfo, remainingIndices } = channelRanges[0];
4311
+ const groups = groupContiguousIndices(channelInfo, remainingIndices);
4312
+ for (const group of groups) {
4313
+ allGroups.push({
4314
+ group,
4315
+ channelRangeEntries: channelRanges.map(({ ch, channelInfo: ci }) => ({
4316
+ ch,
4317
+ channelInfo: ci
4318
+ }))
4259
4319
  });
4320
+ }
4321
+ }
4322
+ for (const { group, channelRangeEntries } of allGroups) {
4323
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4324
+ await new Promise((resolve) => {
4325
+ if (typeof requestIdleCallback === "function") {
4326
+ requestIdleCallback(() => resolve());
4327
+ } else {
4328
+ setTimeout(resolve, 0);
4329
+ }
4330
+ });
4331
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4332
+ const { channelInfo: firstChannelInfo } = channelRangeEntries[0];
4333
+ const cacheKey = await computeFFTForChunks(
4334
+ workerApi,
4335
+ firstChannelInfo,
4336
+ group,
4337
+ item,
4338
+ generation
4339
+ );
4340
+ for (const { ch, channelInfo: ci } of channelRangeEntries) {
4260
4341
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4261
- await renderChunkSubset(workerApi, cacheKey, channelInfo, batch, item, ch);
4342
+ await renderChunkSubset(workerApi, cacheKey, ci, group, item, ch, generation);
4262
4343
  }
4263
4344
  }
4264
4345
  return false;
@@ -4270,117 +4351,76 @@ var SpectrogramProvider = ({
4270
4351
  if (clipCanvasInfo && clipCanvasInfo.size > 0) {
4271
4352
  const numChannels = item.monoFlag ? 1 : item.channelDataArrays.length;
4272
4353
  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
- }
4354
+ const channelRanges = [];
4355
+ for (let ch = 0; ch < numChannels; ch++) {
4356
+ const channelInfo = clipCanvasInfo.get(ch);
4357
+ if (!channelInfo) continue;
4358
+ const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4359
+ channelRanges.push({ ch, channelInfo, ...range });
4303
4360
  }
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
- });
4361
+ if (channelRanges.length > 0 && channelRanges[0].viewportIndices.length > 0) {
4362
+ const cacheKey = await computeFFTForChunks(
4363
+ workerApi,
4364
+ channelRanges[0].channelInfo,
4365
+ channelRanges[0].viewportIndices,
4366
+ item,
4367
+ generation
4368
+ );
4316
4369
  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);
4370
+ for (const { ch, channelInfo, viewportIndices } of channelRanges) {
4321
4371
  await renderChunkSubset(
4322
4372
  workerApi,
4323
- visibleCacheKey,
4373
+ cacheKey,
4324
4374
  channelInfo,
4325
- visibleIndices,
4375
+ viewportIndices,
4326
4376
  item,
4327
- ch
4377
+ ch,
4378
+ generation
4328
4379
  );
4329
4380
  }
4330
- if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4331
4381
  }
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
4382
  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
4383
+ if (channelRanges.length > 0 && channelRanges[0].bufferIndices.length > 0) {
4384
+ const bufferGroups = groupContiguousIndices(
4385
+ channelRanges[0].channelInfo,
4386
+ channelRanges[0].bufferIndices
4356
4387
  );
4388
+ for (const group of bufferGroups) {
4389
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4390
+ const cacheKey = await computeFFTForChunks(
4391
+ workerApi,
4392
+ channelRanges[0].channelInfo,
4393
+ group,
4394
+ item,
4395
+ generation
4396
+ );
4397
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4398
+ for (const { ch, channelInfo } of channelRanges) {
4399
+ await renderChunkSubset(
4400
+ workerApi,
4401
+ cacheKey,
4402
+ channelInfo,
4403
+ group,
4404
+ item,
4405
+ ch,
4406
+ generation
4407
+ );
4408
+ }
4409
+ }
4357
4410
  }
4411
+ renderedClipIdsRef.current.add(item.clipId);
4358
4412
  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
- });
4413
+ if (await renderBackgroundBatches(channelRanges, item)) return;
4375
4414
  }
4376
4415
  } catch (err) {
4377
- console.warn("Spectrogram worker error for clip", item.clipId, err);
4416
+ if (err instanceof SpectrogramAbortError) return;
4417
+ console.warn(
4418
+ `[waveform-playlist] Spectrogram worker error for clip ${item.clipId}: ${err instanceof Error ? err.message : String(err)}`
4419
+ );
4378
4420
  }
4379
4421
  }
4380
4422
  for (const item of clipsNeedingDisplayOnly) {
4381
4423
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4382
- const cacheKey = clipCacheKeysRef.current.get(item.clipId);
4383
- if (!cacheKey) continue;
4384
4424
  const clipCanvasInfo = spectrogramCanvasRegistryRef.current.get(item.clipId);
4385
4425
  if (!clipCanvasInfo || clipCanvasInfo.size === 0) continue;
4386
4426
  try {
@@ -4389,22 +4429,71 @@ var SpectrogramProvider = ({
4389
4429
  for (let ch = 0; ch < item.numChannels; ch++) {
4390
4430
  const channelInfo = clipCanvasInfo.get(ch);
4391
4431
  if (!channelInfo) continue;
4392
- const { visibleIndices, remainingIndices } = getVisibleChunkRange(
4393
- channelInfo,
4394
- clipPixelOffset
4432
+ const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4433
+ channelRanges.push({ ch, channelInfo, ...range });
4434
+ }
4435
+ if (channelRanges.length > 0 && channelRanges[0].viewportIndices.length > 0) {
4436
+ const cacheKey = await computeFFTForChunks(
4437
+ workerApi,
4438
+ channelRanges[0].channelInfo,
4439
+ channelRanges[0].viewportIndices,
4440
+ item,
4441
+ generation
4442
+ );
4443
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4444
+ for (const { ch, channelInfo, viewportIndices } of channelRanges) {
4445
+ await renderChunkSubset(
4446
+ workerApi,
4447
+ cacheKey,
4448
+ channelInfo,
4449
+ viewportIndices,
4450
+ item,
4451
+ ch,
4452
+ generation
4453
+ );
4454
+ }
4455
+ }
4456
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4457
+ if (channelRanges.length > 0 && channelRanges[0].bufferIndices.length > 0) {
4458
+ const bufferGroups = groupContiguousIndices(
4459
+ channelRanges[0].channelInfo,
4460
+ channelRanges[0].bufferIndices
4395
4461
  );
4396
- channelRanges.push({ ch, channelInfo, remainingIndices });
4397
- await renderChunkSubset(workerApi, cacheKey, channelInfo, visibleIndices, item, ch);
4462
+ for (const group of bufferGroups) {
4463
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4464
+ const cacheKey = await computeFFTForChunks(
4465
+ workerApi,
4466
+ channelRanges[0].channelInfo,
4467
+ group,
4468
+ item,
4469
+ generation
4470
+ );
4471
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4472
+ for (const { ch, channelInfo } of channelRanges) {
4473
+ await renderChunkSubset(
4474
+ workerApi,
4475
+ cacheKey,
4476
+ channelInfo,
4477
+ group,
4478
+ item,
4479
+ ch,
4480
+ generation
4481
+ );
4482
+ }
4483
+ }
4398
4484
  }
4399
4485
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4400
- if (await renderBackgroundBatches(channelRanges, cacheKey, item)) return;
4486
+ if (await renderBackgroundBatches(channelRanges, item)) return;
4401
4487
  } catch (err) {
4402
- console.warn("Spectrogram display re-render error for clip", item.clipId, err);
4488
+ if (err instanceof SpectrogramAbortError) return;
4489
+ console.warn(
4490
+ `[waveform-playlist] Spectrogram display re-render error for clip ${item.clipId}: ${err instanceof Error ? err.message : String(err)}`
4491
+ );
4403
4492
  }
4404
4493
  }
4405
4494
  };
4406
4495
  computeAsync().catch((err) => {
4407
- console.error("[waveform-playlist] Spectrogram computation failed:", err);
4496
+ console.error(`[waveform-playlist] Spectrogram computation failed: ${err instanceof Error ? err.message : String(err)}`);
4408
4497
  });
4409
4498
  }, [
4410
4499
  tracks,
@@ -4415,7 +4504,6 @@ var SpectrogramProvider = ({
4415
4504
  waveHeight,
4416
4505
  samplesPerPixel,
4417
4506
  spectrogramCanvasVersion,
4418
- controls,
4419
4507
  scrollContainerRef
4420
4508
  ]);
4421
4509
  const setTrackRenderMode = useCallback((trackId, mode) => {
@@ -4475,7 +4563,6 @@ var SpectrogramProvider = ({
4475
4563
  );
4476
4564
  const value = useMemo(
4477
4565
  () => ({
4478
- spectrogramDataMap,
4479
4566
  trackSpectrogramOverrides,
4480
4567
  spectrogramWorkerApi: spectrogramWorkerReady ? spectrogramWorkerRef.current : null,
4481
4568
  spectrogramConfig,
@@ -4490,7 +4577,6 @@ var SpectrogramProvider = ({
4490
4577
  getFrequencyScale
4491
4578
  }),
4492
4579
  [
4493
- spectrogramDataMap,
4494
4580
  trackSpectrogramOverrides,
4495
4581
  spectrogramWorkerReady,
4496
4582
  spectrogramConfig,
@@ -4505,12 +4591,14 @@ var SpectrogramProvider = ({
4505
4591
  return /* @__PURE__ */ jsx3(SpectrogramIntegrationProvider, { value, children });
4506
4592
  };
4507
4593
  export {
4594
+ SpectrogramAbortError,
4508
4595
  SpectrogramMenuItems,
4509
4596
  SpectrogramProvider,
4510
4597
  SpectrogramSettingsModal,
4511
4598
  computeSpectrogram,
4512
4599
  computeSpectrogramMono,
4513
4600
  createSpectrogramWorker,
4601
+ createSpectrogramWorkerPool,
4514
4602
  getColorMap,
4515
4603
  getFrequencyScale
4516
4604
  };