@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.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,28 @@ 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(`[waveform-playlist] Spectrogram Web Worker required but unavailable: ${err instanceof Error ? err.message : String(err)}`);
4177
+ return;
4149
4178
  }
4150
4179
  }
4151
4180
  const clipsNeedingFFT = [];
@@ -4162,11 +4191,19 @@ var SpectrogramProvider = ({
4162
4191
  for (const clip of track.clips) {
4163
4192
  if (!clip.audioBuffer) continue;
4164
4193
  const monoFlag = mono || clip.audioBuffer.numberOfChannels === 1;
4165
- if (!trackFFTChanged && !hasRegisteredCanvases && clipCacheKeysRef.current.has(clip.id)) {
4194
+ if (!trackFFTChanged && !hasRegisteredCanvases && renderedClipIdsRef.current.has(clip.id)) {
4195
+ const channelDataArrays2 = [];
4196
+ for (let ch = 0; ch < clip.audioBuffer.numberOfChannels; ch++) {
4197
+ channelDataArrays2.push(clip.audioBuffer.getChannelData(ch));
4198
+ }
4166
4199
  clipsNeedingDisplayOnly.push({
4167
4200
  clipId: clip.id,
4168
4201
  trackIndex: i,
4202
+ channelDataArrays: channelDataArrays2,
4169
4203
  config: cfg,
4204
+ sampleRate: clip.audioBuffer.sampleRate,
4205
+ offsetSamples: clip.offsetSamples,
4206
+ durationSamples: clip.durationSamples,
4170
4207
  clipStartSample: clip.startSample,
4171
4208
  monoFlag,
4172
4209
  colorMap: cm,
@@ -4193,68 +4230,38 @@ var SpectrogramProvider = ({
4193
4230
  }
4194
4231
  });
4195
4232
  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
4233
  const getVisibleChunkRange = (channelInfo, clipPixelOffset = 0) => {
4236
4234
  const container = scrollContainerRef.current;
4237
4235
  if (!container) {
4238
- return { visibleIndices: channelInfo.canvasWidths.map((_, i) => i), remainingIndices: [] };
4236
+ return {
4237
+ viewportIndices: channelInfo.canvasWidths.map((_, i) => i),
4238
+ bufferIndices: [],
4239
+ remainingIndices: []
4240
+ };
4239
4241
  }
4240
4242
  const scrollLeft = container.scrollLeft;
4241
4243
  const viewportWidth = container.clientWidth;
4242
- const controlWidth = controls.show ? controls.width : 0;
4243
- const visibleIndices = [];
4244
+ const buffer = viewportWidth * 1.5;
4245
+ const bufferStart = Math.max(0, scrollLeft - buffer);
4246
+ const bufferEnd = scrollLeft + viewportWidth + buffer;
4247
+ const viewportIndices = [];
4248
+ const bufferIndices = [];
4244
4249
  const remainingIndices = [];
4245
4250
  for (let i = 0; i < channelInfo.canvasWidths.length; i++) {
4246
4251
  const chunkNumber = extractChunkNumber(channelInfo.canvasIds[i]);
4247
- const chunkLeft = chunkNumber * import_core.MAX_CANVAS_WIDTH + controlWidth + clipPixelOffset;
4252
+ const chunkLeft = chunkNumber * import_core.MAX_CANVAS_WIDTH + clipPixelOffset;
4248
4253
  const chunkRight = chunkLeft + channelInfo.canvasWidths[i];
4249
4254
  if (chunkRight > scrollLeft && chunkLeft < scrollLeft + viewportWidth) {
4250
- visibleIndices.push(i);
4255
+ viewportIndices.push(i);
4256
+ } else if (chunkRight > bufferStart && chunkLeft < bufferEnd) {
4257
+ bufferIndices.push(i);
4251
4258
  } else {
4252
4259
  remainingIndices.push(i);
4253
4260
  }
4254
4261
  }
4255
- return { visibleIndices, remainingIndices };
4262
+ return { viewportIndices, bufferIndices, remainingIndices };
4256
4263
  };
4257
- const renderChunkSubset = async (api, cacheKey, channelInfo, indices, item, channelIndex) => {
4264
+ const renderChunkSubset = async (api, cacheKey, channelInfo, indices, item, channelIndex, gen) => {
4258
4265
  if (indices.length === 0) return;
4259
4266
  const canvasIds = indices.map((i) => channelInfo.canvasIds[i]);
4260
4267
  const canvasWidths = indices.map((i) => channelInfo.canvasWidths[i]);
@@ -4264,41 +4271,117 @@ var SpectrogramProvider = ({
4264
4271
  globalPixelOffsets.push(chunkNumber * import_core.MAX_CANVAS_WIDTH);
4265
4272
  }
4266
4273
  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
- });
4274
+ await api.renderChunks(
4275
+ {
4276
+ cacheKey,
4277
+ canvasIds,
4278
+ canvasWidths,
4279
+ globalPixelOffsets,
4280
+ canvasHeight: waveHeight,
4281
+ devicePixelRatio: typeof window !== "undefined" ? window.devicePixelRatio : 1,
4282
+ samplesPerPixel,
4283
+ colorLUT,
4284
+ frequencyScale: item.config.frequencyScale ?? "mel",
4285
+ minFrequency: item.config.minFrequency ?? 0,
4286
+ maxFrequency: item.config.maxFrequency ?? 0,
4287
+ gainDb: item.config.gainDb ?? 20,
4288
+ rangeDb: item.config.rangeDb ?? 80,
4289
+ channelIndex
4290
+ },
4291
+ gen
4292
+ );
4293
+ };
4294
+ const computeFFTForChunks = async (api, channelInfo, indices, item, gen) => {
4295
+ const chunkNumbers = indices.map((i) => extractChunkNumber(channelInfo.canvasIds[i]));
4296
+ const minChunk = Math.min(...chunkNumbers);
4297
+ const maxChunk = Math.max(...chunkNumbers);
4298
+ const maxChunkIdx = indices[chunkNumbers.indexOf(maxChunk)];
4299
+ const lastChunkWidth = channelInfo.canvasWidths[maxChunkIdx];
4300
+ const startPx = minChunk * import_core.MAX_CANVAS_WIDTH;
4301
+ const endPx = maxChunk * import_core.MAX_CANVAS_WIDTH + lastChunkWidth;
4302
+ const windowSize = item.config.fftSize ?? 2048;
4303
+ const rangeStartSample = item.offsetSamples + Math.floor(startPx * samplesPerPixel);
4304
+ const rangeEndSample = Math.min(
4305
+ item.offsetSamples + item.durationSamples,
4306
+ item.offsetSamples + Math.ceil(endPx * samplesPerPixel)
4307
+ );
4308
+ const paddedStart = Math.max(item.offsetSamples, rangeStartSample - windowSize);
4309
+ const paddedEnd = Math.min(
4310
+ item.offsetSamples + item.durationSamples,
4311
+ rangeEndSample + windowSize
4312
+ );
4313
+ const { cacheKey } = await api.computeFFT(
4314
+ {
4315
+ clipId: item.clipId,
4316
+ channelDataArrays: item.channelDataArrays,
4317
+ config: item.config,
4318
+ sampleRate: item.sampleRate,
4319
+ offsetSamples: item.offsetSamples,
4320
+ durationSamples: item.durationSamples,
4321
+ mono: item.monoFlag,
4322
+ sampleRange: { start: paddedStart, end: paddedEnd }
4323
+ },
4324
+ gen
4325
+ );
4326
+ return cacheKey;
4327
+ };
4328
+ const groupContiguousIndices = (channelInfo, indices) => {
4329
+ if (indices.length === 0) return [];
4330
+ const groups = [];
4331
+ let currentGroup = [indices[0]];
4332
+ let prevChunk = extractChunkNumber(channelInfo.canvasIds[indices[0]]);
4333
+ for (let i = 1; i < indices.length; i++) {
4334
+ const chunk = extractChunkNumber(channelInfo.canvasIds[indices[i]]);
4335
+ if (chunk === prevChunk + 1) {
4336
+ currentGroup.push(indices[i]);
4337
+ } else {
4338
+ groups.push(currentGroup);
4339
+ currentGroup = [indices[i]];
4340
+ }
4341
+ prevChunk = chunk;
4342
+ }
4343
+ groups.push(currentGroup);
4344
+ return groups;
4283
4345
  };
4284
4346
  const computeAsync = async () => {
4285
4347
  const abortToken = { aborted: false };
4286
4348
  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
- }
4349
+ const renderBackgroundBatches = async (channelRanges, item) => {
4350
+ const allGroups = [];
4351
+ if (channelRanges.length > 0) {
4352
+ const { channelInfo, remainingIndices } = channelRanges[0];
4353
+ const groups = groupContiguousIndices(channelInfo, remainingIndices);
4354
+ for (const group of groups) {
4355
+ allGroups.push({
4356
+ group,
4357
+ channelRangeEntries: channelRanges.map(({ ch, channelInfo: ci }) => ({
4358
+ ch,
4359
+ channelInfo: ci
4360
+ }))
4299
4361
  });
4362
+ }
4363
+ }
4364
+ for (const { group, channelRangeEntries } of allGroups) {
4365
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4366
+ await new Promise((resolve) => {
4367
+ if (typeof requestIdleCallback === "function") {
4368
+ requestIdleCallback(() => resolve());
4369
+ } else {
4370
+ setTimeout(resolve, 0);
4371
+ }
4372
+ });
4373
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4374
+ const { channelInfo: firstChannelInfo } = channelRangeEntries[0];
4375
+ const cacheKey = await computeFFTForChunks(
4376
+ workerApi,
4377
+ firstChannelInfo,
4378
+ group,
4379
+ item,
4380
+ generation
4381
+ );
4382
+ for (const { ch, channelInfo: ci } of channelRangeEntries) {
4300
4383
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return true;
4301
- await renderChunkSubset(workerApi, cacheKey, channelInfo, batch, item, ch);
4384
+ await renderChunkSubset(workerApi, cacheKey, ci, group, item, ch, generation);
4302
4385
  }
4303
4386
  }
4304
4387
  return false;
@@ -4310,117 +4393,76 @@ var SpectrogramProvider = ({
4310
4393
  if (clipCanvasInfo && clipCanvasInfo.size > 0) {
4311
4394
  const numChannels = item.monoFlag ? 1 : item.channelDataArrays.length;
4312
4395
  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
- }
4396
+ const channelRanges = [];
4397
+ for (let ch = 0; ch < numChannels; ch++) {
4398
+ const channelInfo = clipCanvasInfo.get(ch);
4399
+ if (!channelInfo) continue;
4400
+ const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4401
+ channelRanges.push({ ch, channelInfo, ...range });
4343
4402
  }
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
- });
4403
+ if (channelRanges.length > 0 && channelRanges[0].viewportIndices.length > 0) {
4404
+ const cacheKey = await computeFFTForChunks(
4405
+ workerApi,
4406
+ channelRanges[0].channelInfo,
4407
+ channelRanges[0].viewportIndices,
4408
+ item,
4409
+ generation
4410
+ );
4356
4411
  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);
4412
+ for (const { ch, channelInfo, viewportIndices } of channelRanges) {
4361
4413
  await renderChunkSubset(
4362
4414
  workerApi,
4363
- visibleCacheKey,
4415
+ cacheKey,
4364
4416
  channelInfo,
4365
- visibleIndices,
4417
+ viewportIndices,
4366
4418
  item,
4367
- ch
4419
+ ch,
4420
+ generation
4368
4421
  );
4369
4422
  }
4370
- if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4371
4423
  }
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
4424
  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
4425
+ if (channelRanges.length > 0 && channelRanges[0].bufferIndices.length > 0) {
4426
+ const bufferGroups = groupContiguousIndices(
4427
+ channelRanges[0].channelInfo,
4428
+ channelRanges[0].bufferIndices
4396
4429
  );
4430
+ for (const group of bufferGroups) {
4431
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4432
+ const cacheKey = await computeFFTForChunks(
4433
+ workerApi,
4434
+ channelRanges[0].channelInfo,
4435
+ group,
4436
+ item,
4437
+ generation
4438
+ );
4439
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4440
+ for (const { ch, channelInfo } of channelRanges) {
4441
+ await renderChunkSubset(
4442
+ workerApi,
4443
+ cacheKey,
4444
+ channelInfo,
4445
+ group,
4446
+ item,
4447
+ ch,
4448
+ generation
4449
+ );
4450
+ }
4451
+ }
4397
4452
  }
4453
+ renderedClipIdsRef.current.add(item.clipId);
4398
4454
  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
- });
4455
+ if (await renderBackgroundBatches(channelRanges, item)) return;
4415
4456
  }
4416
4457
  } catch (err) {
4417
- console.warn("Spectrogram worker error for clip", item.clipId, err);
4458
+ if (err instanceof SpectrogramAbortError) return;
4459
+ console.warn(
4460
+ `[waveform-playlist] Spectrogram worker error for clip ${item.clipId}: ${err instanceof Error ? err.message : String(err)}`
4461
+ );
4418
4462
  }
4419
4463
  }
4420
4464
  for (const item of clipsNeedingDisplayOnly) {
4421
4465
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4422
- const cacheKey = clipCacheKeysRef.current.get(item.clipId);
4423
- if (!cacheKey) continue;
4424
4466
  const clipCanvasInfo = spectrogramCanvasRegistryRef.current.get(item.clipId);
4425
4467
  if (!clipCanvasInfo || clipCanvasInfo.size === 0) continue;
4426
4468
  try {
@@ -4429,22 +4471,71 @@ var SpectrogramProvider = ({
4429
4471
  for (let ch = 0; ch < item.numChannels; ch++) {
4430
4472
  const channelInfo = clipCanvasInfo.get(ch);
4431
4473
  if (!channelInfo) continue;
4432
- const { visibleIndices, remainingIndices } = getVisibleChunkRange(
4433
- channelInfo,
4434
- clipPixelOffset
4474
+ const range = getVisibleChunkRange(channelInfo, clipPixelOffset);
4475
+ channelRanges.push({ ch, channelInfo, ...range });
4476
+ }
4477
+ if (channelRanges.length > 0 && channelRanges[0].viewportIndices.length > 0) {
4478
+ const cacheKey = await computeFFTForChunks(
4479
+ workerApi,
4480
+ channelRanges[0].channelInfo,
4481
+ channelRanges[0].viewportIndices,
4482
+ item,
4483
+ generation
4484
+ );
4485
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4486
+ for (const { ch, channelInfo, viewportIndices } of channelRanges) {
4487
+ await renderChunkSubset(
4488
+ workerApi,
4489
+ cacheKey,
4490
+ channelInfo,
4491
+ viewportIndices,
4492
+ item,
4493
+ ch,
4494
+ generation
4495
+ );
4496
+ }
4497
+ }
4498
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4499
+ if (channelRanges.length > 0 && channelRanges[0].bufferIndices.length > 0) {
4500
+ const bufferGroups = groupContiguousIndices(
4501
+ channelRanges[0].channelInfo,
4502
+ channelRanges[0].bufferIndices
4435
4503
  );
4436
- channelRanges.push({ ch, channelInfo, remainingIndices });
4437
- await renderChunkSubset(workerApi, cacheKey, channelInfo, visibleIndices, item, ch);
4504
+ for (const group of bufferGroups) {
4505
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4506
+ const cacheKey = await computeFFTForChunks(
4507
+ workerApi,
4508
+ channelRanges[0].channelInfo,
4509
+ group,
4510
+ item,
4511
+ generation
4512
+ );
4513
+ if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4514
+ for (const { ch, channelInfo } of channelRanges) {
4515
+ await renderChunkSubset(
4516
+ workerApi,
4517
+ cacheKey,
4518
+ channelInfo,
4519
+ group,
4520
+ item,
4521
+ ch,
4522
+ generation
4523
+ );
4524
+ }
4525
+ }
4438
4526
  }
4439
4527
  if (spectrogramGenerationRef.current !== generation || abortToken.aborted) return;
4440
- if (await renderBackgroundBatches(channelRanges, cacheKey, item)) return;
4528
+ if (await renderBackgroundBatches(channelRanges, item)) return;
4441
4529
  } catch (err) {
4442
- console.warn("Spectrogram display re-render error for clip", item.clipId, err);
4530
+ if (err instanceof SpectrogramAbortError) return;
4531
+ console.warn(
4532
+ `[waveform-playlist] Spectrogram display re-render error for clip ${item.clipId}: ${err instanceof Error ? err.message : String(err)}`
4533
+ );
4443
4534
  }
4444
4535
  }
4445
4536
  };
4446
4537
  computeAsync().catch((err) => {
4447
- console.error("[waveform-playlist] Spectrogram computation failed:", err);
4538
+ console.error(`[waveform-playlist] Spectrogram computation failed: ${err instanceof Error ? err.message : String(err)}`);
4448
4539
  });
4449
4540
  }, [
4450
4541
  tracks,
@@ -4455,7 +4546,6 @@ var SpectrogramProvider = ({
4455
4546
  waveHeight,
4456
4547
  samplesPerPixel,
4457
4548
  spectrogramCanvasVersion,
4458
- controls,
4459
4549
  scrollContainerRef
4460
4550
  ]);
4461
4551
  const setTrackRenderMode = (0, import_react2.useCallback)((trackId, mode) => {
@@ -4515,7 +4605,6 @@ var SpectrogramProvider = ({
4515
4605
  );
4516
4606
  const value = (0, import_react2.useMemo)(
4517
4607
  () => ({
4518
- spectrogramDataMap,
4519
4608
  trackSpectrogramOverrides,
4520
4609
  spectrogramWorkerApi: spectrogramWorkerReady ? spectrogramWorkerRef.current : null,
4521
4610
  spectrogramConfig,
@@ -4530,7 +4619,6 @@ var SpectrogramProvider = ({
4530
4619
  getFrequencyScale
4531
4620
  }),
4532
4621
  [
4533
- spectrogramDataMap,
4534
4622
  trackSpectrogramOverrides,
4535
4623
  spectrogramWorkerReady,
4536
4624
  spectrogramConfig,
@@ -4546,12 +4634,14 @@ var SpectrogramProvider = ({
4546
4634
  };
4547
4635
  // Annotate the CommonJS export names for ESM import in node:
4548
4636
  0 && (module.exports = {
4637
+ SpectrogramAbortError,
4549
4638
  SpectrogramMenuItems,
4550
4639
  SpectrogramProvider,
4551
4640
  SpectrogramSettingsModal,
4552
4641
  computeSpectrogram,
4553
4642
  computeSpectrogramMono,
4554
4643
  createSpectrogramWorker,
4644
+ createSpectrogramWorkerPool,
4555
4645
  getColorMap,
4556
4646
  getFrequencyScale
4557
4647
  });