@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.js CHANGED
@@ -30,12 +30,14 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
+ SpectrogramAbortError: () => SpectrogramAbortError,
33
34
  SpectrogramMenuItems: () => SpectrogramMenuItems,
34
35
  SpectrogramProvider: () => SpectrogramProvider,
35
36
  SpectrogramSettingsModal: () => SpectrogramSettingsModal,
36
37
  computeSpectrogram: () => computeSpectrogram,
37
38
  computeSpectrogramMono: () => computeSpectrogramMono,
38
39
  createSpectrogramWorker: () => createSpectrogramWorker,
40
+ createSpectrogramWorkerPool: () => createSpectrogramWorkerPool,
39
41
  getColorMap: () => getColorMap,
40
42
  getFrequencyScale: () => getFrequencyScale
41
43
  });
@@ -3819,6 +3821,12 @@ var SpectrogramSettingsModal = ({
3819
3821
  };
3820
3822
 
3821
3823
  // src/worker/createSpectrogramWorker.ts
3824
+ var SpectrogramAbortError = class extends Error {
3825
+ constructor() {
3826
+ super("aborted");
3827
+ this.name = "SpectrogramAbortError";
3828
+ }
3829
+ };
3822
3830
  function addPending(map, id, resolve, reject) {
3823
3831
  map.set(id, { resolve, reject });
3824
3832
  }
@@ -3836,15 +3844,15 @@ function createSpectrogramWorker(worker) {
3836
3844
  case "error":
3837
3845
  entry.reject(new Error(msg.error));
3838
3846
  break;
3847
+ case "aborted":
3848
+ entry.reject(new SpectrogramAbortError());
3849
+ break;
3839
3850
  case "cache-key":
3840
3851
  entry.resolve({ cacheKey: msg.cacheKey });
3841
3852
  break;
3842
3853
  case "done":
3843
3854
  entry.resolve(void 0);
3844
3855
  break;
3845
- case "spectrograms":
3846
- entry.resolve(msg.spectrograms);
3847
- break;
3848
3856
  }
3849
3857
  } else if (msg.id) {
3850
3858
  console.warn(`[spectrogram] Received response for unknown message ID: ${msg.id}`);
@@ -3858,28 +3866,7 @@ function createSpectrogramWorker(worker) {
3858
3866
  pending.clear();
3859
3867
  };
3860
3868
  return {
3861
- compute(params) {
3862
- if (terminated) return Promise.reject(new Error("Worker terminated"));
3863
- const id = String(++idCounter);
3864
- return new Promise((resolve, reject) => {
3865
- addPending(pending, id, resolve, reject);
3866
- const transferableArrays = params.channelDataArrays.map((arr) => arr.slice());
3867
- const transferables = transferableArrays.map((arr) => arr.buffer);
3868
- worker.postMessage(
3869
- {
3870
- id,
3871
- channelDataArrays: transferableArrays,
3872
- config: params.config,
3873
- sampleRate: params.sampleRate,
3874
- offsetSamples: params.offsetSamples,
3875
- durationSamples: params.durationSamples,
3876
- mono: params.mono
3877
- },
3878
- transferables
3879
- );
3880
- });
3881
- },
3882
- computeFFT(params) {
3869
+ computeFFT(params, generation = 0) {
3883
3870
  if (terminated) return Promise.reject(new Error("Worker terminated"));
3884
3871
  const id = String(++idCounter);
3885
3872
  return new Promise((resolve, reject) => {
@@ -3891,6 +3878,7 @@ function createSpectrogramWorker(worker) {
3891
3878
  {
3892
3879
  type: "compute-fft",
3893
3880
  id,
3881
+ generation,
3894
3882
  clipId: params.clipId,
3895
3883
  channelDataArrays: transferableArrays,
3896
3884
  config: params.config,
@@ -3898,13 +3886,14 @@ function createSpectrogramWorker(worker) {
3898
3886
  offsetSamples: params.offsetSamples,
3899
3887
  durationSamples: params.durationSamples,
3900
3888
  mono: params.mono,
3901
- ...params.sampleRange ? { sampleRange: params.sampleRange } : {}
3889
+ ...params.sampleRange ? { sampleRange: params.sampleRange } : {},
3890
+ ...params.channelFilter !== void 0 ? { channelFilter: params.channelFilter } : {}
3902
3891
  },
3903
3892
  transferables
3904
3893
  );
3905
3894
  });
3906
3895
  },
3907
- renderChunks(params) {
3896
+ renderChunks(params, generation = 0) {
3908
3897
  if (terminated) return Promise.reject(new Error("Worker terminated"));
3909
3898
  const id = String(++idCounter);
3910
3899
  return new Promise((resolve, reject) => {
@@ -3912,6 +3901,7 @@ function createSpectrogramWorker(worker) {
3912
3901
  worker.postMessage({
3913
3902
  type: "render-chunks",
3914
3903
  id,
3904
+ generation,
3915
3905
  cacheKey: params.cacheKey,
3916
3906
  canvasIds: params.canvasIds,
3917
3907
  canvasWidths: params.canvasWidths,
@@ -3929,6 +3919,10 @@ function createSpectrogramWorker(worker) {
3929
3919
  });
3930
3920
  });
3931
3921
  },
3922
+ abortGeneration(generation) {
3923
+ if (terminated) return;
3924
+ worker.postMessage({ type: "abort-generation", generation });
3925
+ },
3932
3926
  registerCanvas(canvasId, canvas) {
3933
3927
  worker.postMessage({ type: "register-canvas", canvasId, canvas }, [canvas]);
3934
3928
  },
@@ -3948,29 +3942,6 @@ function createSpectrogramWorker(worker) {
3948
3942
  worker.postMessage({ type: "unregister-audio-data", clipId });
3949
3943
  registeredClipIds.delete(clipId);
3950
3944
  },
3951
- computeAndRender(params) {
3952
- if (terminated) return Promise.reject(new Error("Worker terminated"));
3953
- const id = String(++idCounter);
3954
- return new Promise((resolve, reject) => {
3955
- addPending(pending, id, resolve, reject);
3956
- const transferableArrays = params.channelDataArrays.map((arr) => arr.slice());
3957
- const transferables = transferableArrays.map((arr) => arr.buffer);
3958
- worker.postMessage(
3959
- {
3960
- type: "compute-render",
3961
- id,
3962
- channelDataArrays: transferableArrays,
3963
- config: params.config,
3964
- sampleRate: params.sampleRate,
3965
- offsetSamples: params.offsetSamples,
3966
- durationSamples: params.durationSamples,
3967
- mono: params.mono,
3968
- render: params.render
3969
- },
3970
- transferables
3971
- );
3972
- });
3973
- },
3974
3945
  terminate() {
3975
3946
  terminated = true;
3976
3947
  worker.terminate();
@@ -3982,6 +3953,76 @@ function createSpectrogramWorker(worker) {
3982
3953
  };
3983
3954
  }
3984
3955
 
3956
+ // src/worker/createSpectrogramWorkerPool.ts
3957
+ function parseChannelFromCanvasId(canvasId) {
3958
+ const match = canvasId.match(/-ch(\d+)-/);
3959
+ return match ? parseInt(match[1], 10) : 0;
3960
+ }
3961
+ function defaultPoolSize() {
3962
+ return 2;
3963
+ }
3964
+ function createSpectrogramWorkerPool(createWorker, poolSize = defaultPoolSize()) {
3965
+ const workers = [];
3966
+ try {
3967
+ for (let i = 0; i < poolSize; i++) {
3968
+ workers.push(createSpectrogramWorker(createWorker()));
3969
+ }
3970
+ } catch (err) {
3971
+ for (const w of workers) {
3972
+ w.terminate();
3973
+ }
3974
+ throw err;
3975
+ }
3976
+ function getWorkerForChannel(channelIndex) {
3977
+ return workers[channelIndex % workers.length];
3978
+ }
3979
+ return {
3980
+ computeFFT(params, generation = 0) {
3981
+ if (params.mono) {
3982
+ return workers[0].computeFFT(params, generation);
3983
+ }
3984
+ const channelCount = params.channelDataArrays.length;
3985
+ const activeWorkers = workers.slice(0, channelCount);
3986
+ const promises = activeWorkers.map(
3987
+ (w, i) => w.computeFFT({ ...params, channelFilter: i }, generation)
3988
+ );
3989
+ return Promise.all(promises).then((results) => results[0]);
3990
+ },
3991
+ renderChunks(params, generation = 0) {
3992
+ const worker = getWorkerForChannel(params.channelIndex);
3993
+ return worker.renderChunks({ ...params, channelIndex: 0 }, generation);
3994
+ },
3995
+ abortGeneration(generation) {
3996
+ for (const w of workers) {
3997
+ w.abortGeneration(generation);
3998
+ }
3999
+ },
4000
+ registerCanvas(canvasId, canvas) {
4001
+ const ch = parseChannelFromCanvasId(canvasId);
4002
+ getWorkerForChannel(ch).registerCanvas(canvasId, canvas);
4003
+ },
4004
+ unregisterCanvas(canvasId) {
4005
+ const ch = parseChannelFromCanvasId(canvasId);
4006
+ getWorkerForChannel(ch).unregisterCanvas(canvasId);
4007
+ },
4008
+ registerAudioData(clipId, channelDataArrays, sampleRate) {
4009
+ for (const w of workers) {
4010
+ w.registerAudioData(clipId, channelDataArrays, sampleRate);
4011
+ }
4012
+ },
4013
+ unregisterAudioData(clipId) {
4014
+ for (const w of workers) {
4015
+ w.unregisterAudioData(clipId);
4016
+ }
4017
+ },
4018
+ terminate() {
4019
+ for (const w of workers) {
4020
+ w.terminate();
4021
+ }
4022
+ }
4023
+ };
4024
+ }
4025
+
3985
4026
  // src/SpectrogramProvider.tsx
3986
4027
  var import_react2 = require("react");
3987
4028
  var import_core = require("@waveform-playlist/core");
@@ -4000,13 +4041,11 @@ function extractChunkNumber(canvasId) {
4000
4041
  var SpectrogramProvider = ({
4001
4042
  config: spectrogramConfig,
4002
4043
  colorMap: spectrogramColorMap,
4044
+ workerPoolSize,
4003
4045
  children
4004
4046
  }) => {
4005
- const { tracks, waveHeight, samplesPerPixel, isReady, mono, controls } = (0, import_browser2.usePlaylistData)();
4047
+ const { tracks, waveHeight, samplesPerPixel, isReady, mono } = (0, import_browser2.usePlaylistData)();
4006
4048
  const { scrollContainerRef } = (0, import_browser2.usePlaylistControls)();
4007
- const [spectrogramDataMap, setSpectrogramDataMap] = (0, import_react2.useState)(
4008
- /* @__PURE__ */ new Map()
4009
- );
4010
4049
  const [trackSpectrogramOverrides, setTrackSpectrogramOverrides] = (0, import_react2.useState)(/* @__PURE__ */ new Map());
4011
4050
  const spectrogramCanvasRegistryRef = (0, import_react2.useRef)(/* @__PURE__ */ new Map());
4012
4051
  const [spectrogramCanvasVersion, setSpectrogramCanvasVersion] = (0, import_react2.useState)(0);
@@ -4016,7 +4055,7 @@ var SpectrogramProvider = ({
4016
4055
  const spectrogramGenerationRef = (0, import_react2.useRef)(0);
4017
4056
  const prevCanvasVersionRef = (0, import_react2.useRef)(0);
4018
4057
  const [spectrogramWorkerReady, setSpectrogramWorkerReady] = (0, import_react2.useState)(false);
4019
- const clipCacheKeysRef = (0, import_react2.useRef)(/* @__PURE__ */ new Map());
4058
+ const renderedClipIdsRef = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
4020
4059
  const backgroundRenderAbortRef = (0, import_react2.useRef)(null);
4021
4060
  const registeredAudioClipIdsRef = (0, import_react2.useRef)(/* @__PURE__ */ new Set());
4022
4061
  (0, import_react2.useEffect)(() => {
@@ -4030,15 +4069,19 @@ var SpectrogramProvider = ({
4030
4069
  let workerApi = spectrogramWorkerRef.current;
4031
4070
  if (!workerApi) {
4032
4071
  try {
4033
- const rawWorker = new Worker(
4034
- new URL("@waveform-playlist/spectrogram/worker/spectrogram.worker", import_meta.url),
4035
- { type: "module" }
4072
+ workerApi = createSpectrogramWorkerPool(
4073
+ () => new Worker(
4074
+ new URL("@waveform-playlist/spectrogram/worker/spectrogram.worker", import_meta.url),
4075
+ { type: "module" }
4076
+ ),
4077
+ workerPoolSize
4036
4078
  );
4037
- workerApi = createSpectrogramWorker(rawWorker);
4038
4079
  spectrogramWorkerRef.current = workerApi;
4039
4080
  setSpectrogramWorkerReady(true);
4040
- } catch {
4041
- console.warn("Spectrogram Web Worker unavailable for pre-transfer");
4081
+ } catch (err) {
4082
+ console.warn(
4083
+ `[waveform-playlist] Spectrogram Web Worker unavailable for pre-transfer: ${err instanceof Error ? err.message : String(err)}`
4084
+ );
4042
4085
  return;
4043
4086
  }
4044
4087
  }
@@ -4110,42 +4153,30 @@ var SpectrogramProvider = ({
4110
4153
  prevSpectrogramConfigRef.current = currentKeys;
4111
4154
  prevSpectrogramFFTKeyRef.current = currentFFTKeys;
4112
4155
  }
4113
- if (configChanged) {
4114
- setSpectrogramDataMap((prevMap) => {
4115
- const activeClipIds = /* @__PURE__ */ new Set();
4116
- for (const track of tracks) {
4117
- const mode = trackSpectrogramOverrides.get(track.id)?.renderMode ?? track.renderMode ?? "waveform";
4118
- if (mode === "spectrogram" || mode === "both") {
4119
- for (const clip of track.clips) {
4120
- activeClipIds.add(clip.id);
4121
- }
4122
- }
4123
- }
4124
- const newMap = new Map(prevMap);
4125
- for (const clipId of newMap.keys()) {
4126
- if (!activeClipIds.has(clipId)) {
4127
- newMap.delete(clipId);
4128
- }
4129
- }
4130
- return newMap;
4131
- });
4132
- }
4133
4156
  if (backgroundRenderAbortRef.current) {
4134
4157
  backgroundRenderAbortRef.current.aborted = true;
4135
4158
  }
4136
4159
  const generation = ++spectrogramGenerationRef.current;
4160
+ if (spectrogramWorkerRef.current) {
4161
+ spectrogramWorkerRef.current.abortGeneration(generation);
4162
+ }
4137
4163
  let workerApi = spectrogramWorkerRef.current;
4138
4164
  if (!workerApi) {
4139
4165
  try {
4140
- const rawWorker = new Worker(
4141
- new URL("@waveform-playlist/spectrogram/worker/spectrogram.worker", import_meta.url),
4142
- { type: "module" }
4166
+ workerApi = createSpectrogramWorkerPool(
4167
+ () => new Worker(
4168
+ new URL("@waveform-playlist/spectrogram/worker/spectrogram.worker", import_meta.url),
4169
+ { type: "module" }
4170
+ ),
4171
+ workerPoolSize
4143
4172
  );
4144
- workerApi = createSpectrogramWorker(rawWorker);
4145
4173
  spectrogramWorkerRef.current = workerApi;
4146
4174
  setSpectrogramWorkerReady(true);
4147
- } catch {
4148
- console.warn("Spectrogram Web Worker unavailable, falling back to synchronous computation");
4175
+ } catch (err) {
4176
+ console.error(
4177
+ `[waveform-playlist] Spectrogram Web Worker required but unavailable: ${err instanceof Error ? err.message : String(err)}`
4178
+ );
4179
+ return;
4149
4180
  }
4150
4181
  }
4151
4182
  const clipsNeedingFFT = [];
@@ -4162,11 +4193,19 @@ var SpectrogramProvider = ({
4162
4193
  for (const clip of track.clips) {
4163
4194
  if (!clip.audioBuffer) continue;
4164
4195
  const monoFlag = mono || clip.audioBuffer.numberOfChannels === 1;
4165
- if (!trackFFTChanged && !hasRegisteredCanvases && clipCacheKeysRef.current.has(clip.id)) {
4196
+ if (!trackFFTChanged && !hasRegisteredCanvases && renderedClipIdsRef.current.has(clip.id)) {
4197
+ const channelDataArrays2 = [];
4198
+ for (let ch = 0; ch < clip.audioBuffer.numberOfChannels; ch++) {
4199
+ channelDataArrays2.push(clip.audioBuffer.getChannelData(ch));
4200
+ }
4166
4201
  clipsNeedingDisplayOnly.push({
4167
4202
  clipId: clip.id,
4168
4203
  trackIndex: i,
4204
+ channelDataArrays: channelDataArrays2,
4169
4205
  config: cfg,
4206
+ sampleRate: clip.audioBuffer.sampleRate,
4207
+ offsetSamples: clip.offsetSamples,
4208
+ durationSamples: clip.durationSamples,
4170
4209
  clipStartSample: clip.startSample,
4171
4210
  monoFlag,
4172
4211
  colorMap: cm,
@@ -4193,68 +4232,38 @@ var SpectrogramProvider = ({
4193
4232
  }
4194
4233
  });
4195
4234
  if (clipsNeedingFFT.length === 0 && clipsNeedingDisplayOnly.length === 0) return;
4196
- if (!workerApi) {
4197
- try {
4198
- setSpectrogramDataMap((prevMap) => {
4199
- const newMap = new Map(prevMap);
4200
- for (const item of clipsNeedingFFT) {
4201
- const clip = tracks.flatMap((t) => t.clips).find((c) => c.id === item.clipId);
4202
- if (!clip?.audioBuffer) continue;
4203
- const channelSpectrograms = [];
4204
- if (item.monoFlag) {
4205
- channelSpectrograms.push(
4206
- computeSpectrogramMono(
4207
- clip.audioBuffer,
4208
- item.config,
4209
- item.offsetSamples,
4210
- item.durationSamples
4211
- )
4212
- );
4213
- } else {
4214
- for (let ch = 0; ch < clip.audioBuffer.numberOfChannels; ch++) {
4215
- channelSpectrograms.push(
4216
- computeSpectrogram(
4217
- clip.audioBuffer,
4218
- item.config,
4219
- item.offsetSamples,
4220
- item.durationSamples,
4221
- ch
4222
- )
4223
- );
4224
- }
4225
- }
4226
- newMap.set(item.clipId, channelSpectrograms);
4227
- }
4228
- return newMap;
4229
- });
4230
- } catch (err) {
4231
- console.error("[waveform-playlist] Synchronous spectrogram computation failed:", err);
4232
- }
4233
- return;
4234
- }
4235
4235
  const getVisibleChunkRange = (channelInfo, clipPixelOffset = 0) => {
4236
4236
  const container = scrollContainerRef.current;
4237
4237
  if (!container) {
4238
- return { visibleIndices: channelInfo.canvasWidths.map((_, i) => i), remainingIndices: [] };
4238
+ return {
4239
+ viewportIndices: channelInfo.canvasWidths.map((_, i) => i),
4240
+ bufferIndices: [],
4241
+ remainingIndices: []
4242
+ };
4239
4243
  }
4240
4244
  const scrollLeft = container.scrollLeft;
4241
4245
  const viewportWidth = container.clientWidth;
4242
- const controlWidth = controls.show ? controls.width : 0;
4243
- const visibleIndices = [];
4246
+ const buffer = viewportWidth * 1.5;
4247
+ const bufferStart = Math.max(0, scrollLeft - buffer);
4248
+ const bufferEnd = scrollLeft + viewportWidth + buffer;
4249
+ const viewportIndices = [];
4250
+ const bufferIndices = [];
4244
4251
  const remainingIndices = [];
4245
4252
  for (let i = 0; i < channelInfo.canvasWidths.length; i++) {
4246
4253
  const chunkNumber = extractChunkNumber(channelInfo.canvasIds[i]);
4247
- const chunkLeft = chunkNumber * import_core.MAX_CANVAS_WIDTH + controlWidth + clipPixelOffset;
4254
+ const chunkLeft = chunkNumber * import_core.MAX_CANVAS_WIDTH + clipPixelOffset;
4248
4255
  const chunkRight = chunkLeft + channelInfo.canvasWidths[i];
4249
4256
  if (chunkRight > scrollLeft && chunkLeft < scrollLeft + viewportWidth) {
4250
- visibleIndices.push(i);
4257
+ viewportIndices.push(i);
4258
+ } else if (chunkRight > bufferStart && chunkLeft < bufferEnd) {
4259
+ bufferIndices.push(i);
4251
4260
  } else {
4252
4261
  remainingIndices.push(i);
4253
4262
  }
4254
4263
  }
4255
- return { visibleIndices, remainingIndices };
4264
+ return { viewportIndices, bufferIndices, remainingIndices };
4256
4265
  };
4257
- const renderChunkSubset = async (api, cacheKey, channelInfo, indices, item, channelIndex) => {
4266
+ const renderChunkSubset = async (api, cacheKey, channelInfo, indices, item, channelIndex, gen) => {
4258
4267
  if (indices.length === 0) return;
4259
4268
  const canvasIds = indices.map((i) => channelInfo.canvasIds[i]);
4260
4269
  const canvasWidths = indices.map((i) => channelInfo.canvasWidths[i]);
@@ -4264,41 +4273,117 @@ var SpectrogramProvider = ({
4264
4273
  globalPixelOffsets.push(chunkNumber * import_core.MAX_CANVAS_WIDTH);
4265
4274
  }
4266
4275
  const colorLUT = getColorMap(item.colorMap);
4267
- await api.renderChunks({
4268
- cacheKey,
4269
- canvasIds,
4270
- canvasWidths,
4271
- globalPixelOffsets,
4272
- canvasHeight: waveHeight,
4273
- devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio : 1,
4274
- samplesPerPixel,
4275
- colorLUT,
4276
- frequencyScale: item.config.frequencyScale ?? "mel",
4277
- minFrequency: item.config.minFrequency ?? 0,
4278
- maxFrequency: item.config.maxFrequency ?? 0,
4279
- gainDb: item.config.gainDb ?? 20,
4280
- rangeDb: item.config.rangeDb ?? 80,
4281
- channelIndex
4282
- });
4276
+ await api.renderChunks(
4277
+ {
4278
+ cacheKey,
4279
+ canvasIds,
4280
+ canvasWidths,
4281
+ globalPixelOffsets,
4282
+ canvasHeight: waveHeight,
4283
+ devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio : 1,
4284
+ samplesPerPixel,
4285
+ colorLUT,
4286
+ frequencyScale: item.config.frequencyScale ?? "mel",
4287
+ minFrequency: item.config.minFrequency ?? 0,
4288
+ maxFrequency: item.config.maxFrequency ?? 0,
4289
+ gainDb: item.config.gainDb ?? 20,
4290
+ rangeDb: item.config.rangeDb ?? 80,
4291
+ channelIndex
4292
+ },
4293
+ gen
4294
+ );
4295
+ };
4296
+ const computeFFTForChunks = async (api, channelInfo, indices, item, gen) => {
4297
+ const chunkNumbers = indices.map((i) => extractChunkNumber(channelInfo.canvasIds[i]));
4298
+ const minChunk = Math.min(...chunkNumbers);
4299
+ const maxChunk = Math.max(...chunkNumbers);
4300
+ const maxChunkIdx = indices[chunkNumbers.indexOf(maxChunk)];
4301
+ const lastChunkWidth = channelInfo.canvasWidths[maxChunkIdx];
4302
+ const startPx = minChunk * import_core.MAX_CANVAS_WIDTH;
4303
+ const endPx = maxChunk * import_core.MAX_CANVAS_WIDTH + lastChunkWidth;
4304
+ const windowSize = item.config.fftSize ?? 2048;
4305
+ const rangeStartSample = item.offsetSamples + Math.floor(startPx * samplesPerPixel);
4306
+ const rangeEndSample = Math.min(
4307
+ item.offsetSamples + item.durationSamples,
4308
+ item.offsetSamples + Math.ceil(endPx * samplesPerPixel)
4309
+ );
4310
+ const paddedStart = Math.max(item.offsetSamples, rangeStartSample - windowSize);
4311
+ const paddedEnd = Math.min(
4312
+ item.offsetSamples + item.durationSamples,
4313
+ rangeEndSample + windowSize
4314
+ );
4315
+ const { cacheKey } = await api.computeFFT(
4316
+ {
4317
+ clipId: item.clipId,
4318
+ channelDataArrays: item.channelDataArrays,
4319
+ config: item.config,
4320
+ sampleRate: item.sampleRate,
4321
+ offsetSamples: item.offsetSamples,
4322
+ durationSamples: item.durationSamples,
4323
+ mono: item.monoFlag,
4324
+ sampleRange: { start: paddedStart, end: paddedEnd }
4325
+ },
4326
+ gen
4327
+ );
4328
+ return cacheKey;
4329
+ };
4330
+ const groupContiguousIndices = (channelInfo, indices) => {
4331
+ if (indices.length === 0) return [];
4332
+ const groups = [];
4333
+ let currentGroup = [indices[0]];
4334
+ let prevChunk = extractChunkNumber(channelInfo.canvasIds[indices[0]]);
4335
+ for (let i = 1; i < indices.length; i++) {
4336
+ const chunk = extractChunkNumber(channelInfo.canvasIds[indices[i]]);
4337
+ if (chunk === prevChunk + 1) {
4338
+ currentGroup.push(indices[i]);
4339
+ } else {
4340
+ groups.push(currentGroup);
4341
+ currentGroup = [indices[i]];
4342
+ }
4343
+ prevChunk = chunk;
4344
+ }
4345
+ groups.push(currentGroup);
4346
+ return groups;
4283
4347
  };
4284
4348
  const computeAsync = async () => {
4285
4349
  const abortToken = { aborted: false };
4286
4350
  backgroundRenderAbortRef.current = abortToken;
4287
- const renderBackgroundBatches = async (channelRanges, cacheKey, item) => {
4288
- const BATCH_SIZE = 4;
4289
- for (const { ch, channelInfo, remainingIndices } of channelRanges) {
4290
- for (let batchStart = 0; batchStart < remainingIndices.length; batchStart += BATCH_SIZE) {
4291
- if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4292
- const batch = remainingIndices.slice(batchStart, batchStart + BATCH_SIZE);
4293
- await new Promise((resolve) => {
4294
- if (typeof requestIdleCallback === "function") {
4295
- requestIdleCallback(() => resolve());
4296
- } else {
4297
- setTimeout(resolve, 0);
4298
- }
4351
+ const renderBackgroundBatches = async (channelRanges, item) => {
4352
+ const allGroups = [];
4353
+ if (channelRanges.length > 0) {
4354
+ const { channelInfo, remainingIndices } = channelRanges[0];
4355
+ const groups = groupContiguousIndices(channelInfo, remainingIndices);
4356
+ for (const group of groups) {
4357
+ allGroups.push({
4358
+ group,
4359
+ channelRangeEntries: channelRanges.map(({ ch, channelInfo: ci }) => ({
4360
+ ch,
4361
+ channelInfo: ci
4362
+ }))
4299
4363
  });
4364
+ }
4365
+ }
4366
+ for (const { group, channelRangeEntries } of allGroups) {
4367
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4368
+ await new Promise((resolve) => {
4369
+ if (typeof requestIdleCallback === "function") {
4370
+ requestIdleCallback(() => resolve());
4371
+ } else {
4372
+ setTimeout(resolve, 0);
4373
+ }
4374
+ });
4375
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4376
+ const { channelInfo: firstChannelInfo } = channelRangeEntries[0];
4377
+ const cacheKey = await computeFFTForChunks(
4378
+ workerApi,
4379
+ firstChannelInfo,
4380
+ group,
4381
+ item,
4382
+ generation
4383
+ );
4384
+ for (const { ch, channelInfo: ci } of channelRangeEntries) {
4300
4385
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4301
- await renderChunkSubset(workerApi, cacheKey, channelInfo, batch, item, ch);
4386
+ await renderChunkSubset(workerApi, cacheKey, ci, group, item, ch, generation);
4302
4387
  }
4303
4388
  }
4304
4389
  return false;
@@ -4310,117 +4395,76 @@ var SpectrogramProvider = ({
4310
4395
  if (clipCanvasInfo && clipCanvasInfo.size > 0) {
4311
4396
  const numChannels = item.monoFlag ? 1 : item.channelDataArrays.length;
4312
4397
  const clipPixelOffset = Math.floor(item.clipStartSample / samplesPerPixel);
4313
- const container = scrollContainerRef.current;
4314
- const windowSize = item.config.fftSize ?? 2048;
4315
- let visibleRange;
4316
- if (container) {
4317
- const scrollLeft = container.scrollLeft;
4318
- const viewportWidth = container.clientWidth;
4319
- const controlWidth = controls.show ? controls.width : 0;
4320
- const vpStartPx = Math.max(0, scrollLeft - controlWidth);
4321
- const vpEndPx = vpStartPx + viewportWidth;
4322
- const clipStartPx = clipPixelOffset;
4323
- const clipEndPx = clipStartPx + Math.ceil(item.durationSamples / samplesPerPixel);
4324
- const overlapStartPx = Math.max(vpStartPx, clipStartPx);
4325
- const overlapEndPx = Math.min(vpEndPx, clipEndPx);
4326
- if (overlapEndPx > overlapStartPx) {
4327
- const localStartPx = overlapStartPx - clipStartPx;
4328
- const localEndPx = overlapEndPx - clipStartPx;
4329
- const visStartSample = item.offsetSamples + Math.floor(localStartPx * samplesPerPixel);
4330
- const visEndSample = Math.min(
4331
- item.offsetSamples + item.durationSamples,
4332
- item.offsetSamples + Math.ceil(localEndPx * samplesPerPixel)
4333
- );
4334
- const paddedStart = Math.max(item.offsetSamples, visStartSample - windowSize);
4335
- const paddedEnd = Math.min(
4336
- item.offsetSamples + item.durationSamples,
4337
- visEndSample + windowSize
4338
- );
4339
- if (paddedEnd - paddedStart < item.durationSamples * 0.8) {
4340
- visibleRange = { start: paddedStart, end: paddedEnd };
4341
- }
4342
- }
4398
+ const channelRanges = [];
4399
+ for (let ch = 0; ch < numChannels; ch++) {
4400
+ const channelInfo = clipCanvasInfo.get(ch);
4401
+ if (!channelInfo) continue;
4402
+ const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4403
+ channelRanges.push({ ch, channelInfo, ...range });
4343
4404
  }
4344
- const fullClipAlreadyCached = clipCacheKeysRef.current.has(item.clipId);
4345
- if (visibleRange && !fullClipAlreadyCached) {
4346
- const { cacheKey: visibleCacheKey } = await workerApi.computeFFT({
4347
- clipId: item.clipId,
4348
- channelDataArrays: item.channelDataArrays,
4349
- config: item.config,
4350
- sampleRate: item.sampleRate,
4351
- offsetSamples: item.offsetSamples,
4352
- durationSamples: item.durationSamples,
4353
- mono: item.monoFlag,
4354
- sampleRange: visibleRange
4355
- });
4405
+ if (channelRanges.length > 0 && channelRanges[0].viewportIndices.length > 0) {
4406
+ const cacheKey = await computeFFTForChunks(
4407
+ workerApi,
4408
+ channelRanges[0].channelInfo,
4409
+ channelRanges[0].viewportIndices,
4410
+ item,
4411
+ generation
4412
+ );
4356
4413
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4357
- for (let ch = 0; ch < numChannels; ch++) {
4358
- const channelInfo = clipCanvasInfo.get(ch);
4359
- if (!channelInfo) continue;
4360
- const { visibleIndices } = getVisibleChunkRange(channelInfo, clipPixelOffset);
4414
+ for (const { ch, channelInfo, viewportIndices } of channelRanges) {
4361
4415
  await renderChunkSubset(
4362
4416
  workerApi,
4363
- visibleCacheKey,
4417
+ cacheKey,
4364
4418
  channelInfo,
4365
- visibleIndices,
4419
+ viewportIndices,
4366
4420
  item,
4367
- ch
4421
+ ch,
4422
+ generation
4368
4423
  );
4369
4424
  }
4370
- if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4371
4425
  }
4372
- const { cacheKey } = await workerApi.computeFFT({
4373
- clipId: item.clipId,
4374
- channelDataArrays: item.channelDataArrays,
4375
- config: item.config,
4376
- sampleRate: item.sampleRate,
4377
- offsetSamples: item.offsetSamples,
4378
- durationSamples: item.durationSamples,
4379
- mono: item.monoFlag
4380
- });
4381
4426
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4382
- clipCacheKeysRef.current.set(item.clipId, cacheKey);
4383
- const channelRanges = [];
4384
- for (let ch = 0; ch < numChannels; ch++) {
4385
- const channelInfo = clipCanvasInfo.get(ch);
4386
- if (!channelInfo) continue;
4387
- const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4388
- channelRanges.push({ ch, channelInfo, ...range });
4389
- await renderChunkSubset(
4390
- workerApi,
4391
- cacheKey,
4392
- channelInfo,
4393
- range.visibleIndices,
4394
- item,
4395
- ch
4427
+ if (channelRanges.length > 0 && channelRanges[0].bufferIndices.length > 0) {
4428
+ const bufferGroups = groupContiguousIndices(
4429
+ channelRanges[0].channelInfo,
4430
+ channelRanges[0].bufferIndices
4396
4431
  );
4432
+ for (const group of bufferGroups) {
4433
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4434
+ const cacheKey = await computeFFTForChunks(
4435
+ workerApi,
4436
+ channelRanges[0].channelInfo,
4437
+ group,
4438
+ item,
4439
+ generation
4440
+ );
4441
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4442
+ for (const { ch, channelInfo } of channelRanges) {
4443
+ await renderChunkSubset(
4444
+ workerApi,
4445
+ cacheKey,
4446
+ channelInfo,
4447
+ group,
4448
+ item,
4449
+ ch,
4450
+ generation
4451
+ );
4452
+ }
4453
+ }
4397
4454
  }
4455
+ renderedClipIdsRef.current.add(item.clipId);
4398
4456
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4399
- if (await renderBackgroundBatches(channelRanges, cacheKey, item)) return;
4400
- } else {
4401
- const spectrograms = await workerApi.compute({
4402
- channelDataArrays: item.channelDataArrays,
4403
- config: item.config,
4404
- sampleRate: item.sampleRate,
4405
- offsetSamples: item.offsetSamples,
4406
- durationSamples: item.durationSamples,
4407
- mono: item.monoFlag
4408
- });
4409
- if (spectrogramGenerationRef.current !== generation) return;
4410
- setSpectrogramDataMap((prevMap) => {
4411
- const newMap = new Map(prevMap);
4412
- newMap.set(item.clipId, spectrograms);
4413
- return newMap;
4414
- });
4457
+ if (await renderBackgroundBatches(channelRanges, item)) return;
4415
4458
  }
4416
4459
  } catch (err) {
4417
- console.warn("Spectrogram worker error for clip", item.clipId, err);
4460
+ if (err instanceof SpectrogramAbortError) return;
4461
+ console.warn(
4462
+ `[waveform-playlist] Spectrogram worker error for clip ${item.clipId}: ${err instanceof Error ? err.message : String(err)}`
4463
+ );
4418
4464
  }
4419
4465
  }
4420
4466
  for (const item of clipsNeedingDisplayOnly) {
4421
4467
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4422
- const cacheKey = clipCacheKeysRef.current.get(item.clipId);
4423
- if (!cacheKey) continue;
4424
4468
  const clipCanvasInfo = spectrogramCanvasRegistryRef.current.get(item.clipId);
4425
4469
  if (!clipCanvasInfo || clipCanvasInfo.size === 0) continue;
4426
4470
  try {
@@ -4429,22 +4473,73 @@ var SpectrogramProvider = ({
4429
4473
  for (let ch = 0; ch < item.numChannels; ch++) {
4430
4474
  const channelInfo = clipCanvasInfo.get(ch);
4431
4475
  if (!channelInfo) continue;
4432
- const { visibleIndices, remainingIndices } = getVisibleChunkRange(
4433
- channelInfo,
4434
- clipPixelOffset
4476
+ const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4477
+ channelRanges.push({ ch, channelInfo, ...range });
4478
+ }
4479
+ if (channelRanges.length > 0 && channelRanges[0].viewportIndices.length > 0) {
4480
+ const cacheKey = await computeFFTForChunks(
4481
+ workerApi,
4482
+ channelRanges[0].channelInfo,
4483
+ channelRanges[0].viewportIndices,
4484
+ item,
4485
+ generation
4486
+ );
4487
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4488
+ for (const { ch, channelInfo, viewportIndices } of channelRanges) {
4489
+ await renderChunkSubset(
4490
+ workerApi,
4491
+ cacheKey,
4492
+ channelInfo,
4493
+ viewportIndices,
4494
+ item,
4495
+ ch,
4496
+ generation
4497
+ );
4498
+ }
4499
+ }
4500
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4501
+ if (channelRanges.length > 0 && channelRanges[0].bufferIndices.length > 0) {
4502
+ const bufferGroups = groupContiguousIndices(
4503
+ channelRanges[0].channelInfo,
4504
+ channelRanges[0].bufferIndices
4435
4505
  );
4436
- channelRanges.push({ ch, channelInfo, remainingIndices });
4437
- await renderChunkSubset(workerApi, cacheKey, channelInfo, visibleIndices, item, ch);
4506
+ for (const group of bufferGroups) {
4507
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4508
+ const cacheKey = await computeFFTForChunks(
4509
+ workerApi,
4510
+ channelRanges[0].channelInfo,
4511
+ group,
4512
+ item,
4513
+ generation
4514
+ );
4515
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4516
+ for (const { ch, channelInfo } of channelRanges) {
4517
+ await renderChunkSubset(
4518
+ workerApi,
4519
+ cacheKey,
4520
+ channelInfo,
4521
+ group,
4522
+ item,
4523
+ ch,
4524
+ generation
4525
+ );
4526
+ }
4527
+ }
4438
4528
  }
4439
4529
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4440
- if (await renderBackgroundBatches(channelRanges, cacheKey, item)) return;
4530
+ if (await renderBackgroundBatches(channelRanges, item)) return;
4441
4531
  } catch (err) {
4442
- console.warn("Spectrogram display re-render error for clip", item.clipId, err);
4532
+ if (err instanceof SpectrogramAbortError) return;
4533
+ console.warn(
4534
+ `[waveform-playlist] Spectrogram display re-render error for clip ${item.clipId}: ${err instanceof Error ? err.message : String(err)}`
4535
+ );
4443
4536
  }
4444
4537
  }
4445
4538
  };
4446
4539
  computeAsync().catch((err) => {
4447
- console.error("[waveform-playlist] Spectrogram computation failed:", err);
4540
+ console.error(
4541
+ `[waveform-playlist] Spectrogram computation failed: ${err instanceof Error ? err.message : String(err)}`
4542
+ );
4448
4543
  });
4449
4544
  }, [
4450
4545
  tracks,
@@ -4455,7 +4550,6 @@ var SpectrogramProvider = ({
4455
4550
  waveHeight,
4456
4551
  samplesPerPixel,
4457
4552
  spectrogramCanvasVersion,
4458
- controls,
4459
4553
  scrollContainerRef
4460
4554
  ]);
4461
4555
  const setTrackRenderMode = (0, import_react2.useCallback)((trackId, mode) => {
@@ -4515,7 +4609,6 @@ var SpectrogramProvider = ({
4515
4609
  );
4516
4610
  const value = (0, import_react2.useMemo)(
4517
4611
  () => ({
4518
- spectrogramDataMap,
4519
4612
  trackSpectrogramOverrides,
4520
4613
  spectrogramWorkerApi: spectrogramWorkerReady ? spectrogramWorkerRef.current : null,
4521
4614
  spectrogramConfig,
@@ -4530,7 +4623,6 @@ var SpectrogramProvider = ({
4530
4623
  getFrequencyScale
4531
4624
  }),
4532
4625
  [
4533
- spectrogramDataMap,
4534
4626
  trackSpectrogramOverrides,
4535
4627
  spectrogramWorkerReady,
4536
4628
  spectrogramConfig,
@@ -4546,12 +4638,14 @@ var SpectrogramProvider = ({
4546
4638
  };
4547
4639
  // Annotate the CommonJS export names for ESM import in node:
4548
4640
  0 && (module.exports = {
4641
+ SpectrogramAbortError,
4549
4642
  SpectrogramMenuItems,
4550
4643
  SpectrogramProvider,
4551
4644
  SpectrogramSettingsModal,
4552
4645
  computeSpectrogram,
4553
4646
  computeSpectrogramMono,
4554
4647
  createSpectrogramWorker,
4648
+ createSpectrogramWorkerPool,
4555
4649
  getColorMap,
4556
4650
  getFrequencyScale
4557
4651
  });