waa-play 0.2.0 → 0.2.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.
Files changed (127) hide show
  1. package/README.md +41 -89
  2. package/dist/adapters.cjs +6 -6
  3. package/dist/adapters.d.cts +1 -1
  4. package/dist/adapters.d.ts +1 -1
  5. package/dist/adapters.js +1 -1
  6. package/dist/buffer.cjs +5 -5
  7. package/dist/buffer.d.cts +1 -1
  8. package/dist/buffer.d.ts +1 -1
  9. package/dist/buffer.js +1 -1
  10. package/dist/{chunk-T74FBKTY.js → chunk-2FGUFHZM.js} +2 -2
  11. package/dist/{chunk-CPAT75WD.cjs → chunk-3VTU5OX5.cjs} +2 -2
  12. package/dist/{chunk-2DL7CAEP.js → chunk-7JUVBZ6B.js} +2 -2
  13. package/dist/{chunk-D5CD5KQZ.cjs → chunk-BRS7LZVH.cjs} +2 -2
  14. package/dist/{chunk-QWNV2BZ5.cjs → chunk-F6WXD3XW.cjs} +2 -2
  15. package/dist/{chunk-C2ASIYN5.js → chunk-FESPIMZM.js} +3 -7
  16. package/dist/{chunk-GYH2JSCY.js → chunk-FY273Z3I.js} +2 -2
  17. package/dist/{chunk-SIMLANWE.cjs → chunk-G37HMZEX.cjs} +1028 -955
  18. package/dist/{chunk-2FFORBOP.js → chunk-GDBOHOGF.js} +1027 -955
  19. package/dist/{chunk-5J7S6QV3.cjs → chunk-HIF3UAF3.cjs} +2 -2
  20. package/dist/{chunk-CRODJ4KS.js → chunk-HTN52U23.js} +13 -6
  21. package/dist/{chunk-X4IFO7U7.js → chunk-HYRDCTBO.js} +143 -116
  22. package/dist/{chunk-VKT7YCWK.js → chunk-JIHPQAEA.js} +6 -3
  23. package/dist/chunk-KVKW7W66.cjs +148 -0
  24. package/dist/{chunk-4LNVRSTM.cjs → chunk-OIY6I4TU.cjs} +3 -7
  25. package/dist/{chunk-7S5KWTZ6.cjs → chunk-OZN5X4N6.cjs} +6 -3
  26. package/dist/{chunk-CJJC6ASU.js → chunk-PL4J3NR7.js} +2 -2
  27. package/dist/{chunk-IMNRPYBM.js → chunk-QFJQU7TQ.js} +10 -10
  28. package/dist/{chunk-M5PDY5EZ.cjs → chunk-QGZGERGK.cjs} +2 -2
  29. package/dist/{chunk-QFFQQMU4.cjs → chunk-VOSIA3GF.cjs} +13 -6
  30. package/dist/{chunk-CTUCTTIE.cjs → chunk-VY4UMZMJ.cjs} +145 -118
  31. package/dist/{chunk-LETS7FKB.js → chunk-YFK7ETCF.js} +2 -2
  32. package/dist/context.d.cts +1 -1
  33. package/dist/context.d.ts +1 -1
  34. package/dist/emitter.cjs +2 -2
  35. package/dist/emitter.js +1 -1
  36. package/dist/engine-7DCOERRN.js +4 -0
  37. package/dist/engine-ALWPAIX6.cjs +17 -0
  38. package/dist/fade.cjs +5 -5
  39. package/dist/fade.d.cts +1 -1
  40. package/dist/fade.d.ts +1 -1
  41. package/dist/fade.js +1 -1
  42. package/dist/index.cjs +44 -44
  43. package/dist/index.d.cts +7 -7
  44. package/dist/index.d.ts +7 -7
  45. package/dist/index.js +10 -10
  46. package/dist/nodes.cjs +11 -11
  47. package/dist/nodes.js +1 -1
  48. package/dist/play.cjs +3 -3
  49. package/dist/play.d.cts +1 -1
  50. package/dist/play.d.ts +1 -1
  51. package/dist/play.js +2 -2
  52. package/dist/player.cjs +11 -11
  53. package/dist/player.d.cts +1 -1
  54. package/dist/player.d.ts +1 -1
  55. package/dist/player.js +10 -10
  56. package/dist/scheduler.cjs +3 -3
  57. package/dist/scheduler.d.cts +1 -1
  58. package/dist/scheduler.d.ts +1 -1
  59. package/dist/scheduler.js +1 -1
  60. package/dist/stretcher.cjs +3 -3
  61. package/dist/stretcher.d.cts +4 -4
  62. package/dist/stretcher.d.ts +4 -4
  63. package/dist/stretcher.js +2 -2
  64. package/dist/synth.cjs +4 -4
  65. package/dist/synth.js +1 -1
  66. package/dist/{types-DUrbEbPl.d.cts → types-BYC6m7Q0.d.cts} +6 -6
  67. package/dist/{types-DUrbEbPl.d.ts → types-BYC6m7Q0.d.ts} +6 -6
  68. package/dist/waveform.cjs +4 -4
  69. package/dist/waveform.d.cts +1 -1
  70. package/dist/waveform.d.ts +1 -1
  71. package/dist/waveform.js +1 -1
  72. package/package.json +14 -6
  73. package/dist/adapters.cjs.map +0 -1
  74. package/dist/adapters.js.map +0 -1
  75. package/dist/buffer.cjs.map +0 -1
  76. package/dist/buffer.js.map +0 -1
  77. package/dist/chunk-2DL7CAEP.js.map +0 -1
  78. package/dist/chunk-2FFORBOP.js.map +0 -1
  79. package/dist/chunk-37CPPRLV.js.map +0 -1
  80. package/dist/chunk-4LNVRSTM.cjs.map +0 -1
  81. package/dist/chunk-5J7S6QV3.cjs.map +0 -1
  82. package/dist/chunk-6UTN73HG.cjs.map +0 -1
  83. package/dist/chunk-7S5KWTZ6.cjs.map +0 -1
  84. package/dist/chunk-C2ASIYN5.js.map +0 -1
  85. package/dist/chunk-CJJC6ASU.js.map +0 -1
  86. package/dist/chunk-CPAT75WD.cjs.map +0 -1
  87. package/dist/chunk-CRODJ4KS.js.map +0 -1
  88. package/dist/chunk-CTUCTTIE.cjs.map +0 -1
  89. package/dist/chunk-D5CD5KQZ.cjs.map +0 -1
  90. package/dist/chunk-GYH2JSCY.js.map +0 -1
  91. package/dist/chunk-IMNRPYBM.js.map +0 -1
  92. package/dist/chunk-LETS7FKB.js.map +0 -1
  93. package/dist/chunk-M5PDY5EZ.cjs.map +0 -1
  94. package/dist/chunk-QFFQQMU4.cjs.map +0 -1
  95. package/dist/chunk-QWNV2BZ5.cjs.map +0 -1
  96. package/dist/chunk-SIMLANWE.cjs.map +0 -1
  97. package/dist/chunk-T74FBKTY.js.map +0 -1
  98. package/dist/chunk-VKT7YCWK.js.map +0 -1
  99. package/dist/chunk-X4IFO7U7.js.map +0 -1
  100. package/dist/chunk-XZBMBZA3.cjs +0 -148
  101. package/dist/chunk-XZBMBZA3.cjs.map +0 -1
  102. package/dist/context.cjs.map +0 -1
  103. package/dist/context.js.map +0 -1
  104. package/dist/emitter.cjs.map +0 -1
  105. package/dist/emitter.js.map +0 -1
  106. package/dist/engine-QUMYW73L.cjs +0 -13
  107. package/dist/engine-QUMYW73L.cjs.map +0 -1
  108. package/dist/engine-TYI7OX7O.js +0 -4
  109. package/dist/engine-TYI7OX7O.js.map +0 -1
  110. package/dist/fade.cjs.map +0 -1
  111. package/dist/fade.js.map +0 -1
  112. package/dist/index.cjs.map +0 -1
  113. package/dist/index.js.map +0 -1
  114. package/dist/nodes.cjs.map +0 -1
  115. package/dist/nodes.js.map +0 -1
  116. package/dist/play.cjs.map +0 -1
  117. package/dist/play.js.map +0 -1
  118. package/dist/player.cjs.map +0 -1
  119. package/dist/player.js.map +0 -1
  120. package/dist/scheduler.cjs.map +0 -1
  121. package/dist/scheduler.js.map +0 -1
  122. package/dist/stretcher.cjs.map +0 -1
  123. package/dist/stretcher.js.map +0 -1
  124. package/dist/synth.cjs.map +0 -1
  125. package/dist/synth.js.map +0 -1
  126. package/dist/waveform.cjs.map +0 -1
  127. package/dist/waveform.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  'use strict';
2
2
 
3
- var chunk5J7S6QV3_cjs = require('./chunk-5J7S6QV3.cjs');
3
+ var chunkHIF3UAF3_cjs = require('./chunk-HIF3UAF3.cjs');
4
4
 
5
5
  // src/stretcher/constants.ts
6
6
  var CHUNK_DURATION_SEC = 8;
@@ -24,7 +24,300 @@ var WORKER_POOL_SIZE = 2;
24
24
  var MAX_WORKER_CRASHES = 3;
25
25
  var MAX_CHUNK_RETRIES = 3;
26
26
  var LOOKAHEAD_INTERVAL_MS = 200;
27
- var LOOKAHEAD_THRESHOLD_SEC = 1.5;
27
+ var LOOKAHEAD_THRESHOLD_SEC = 3;
28
+ var PROACTIVE_SCHEDULE_THRESHOLD_SEC = 5;
29
+
30
+ // src/stretcher/buffer-monitor.ts
31
+ function createBufferMonitor(options) {
32
+ const healthySec = BUFFER_HEALTHY_SEC;
33
+ const lowSec = BUFFER_LOW_SEC;
34
+ const criticalSec = BUFFER_CRITICAL_SEC;
35
+ const resumeSec = BUFFER_RESUME_SEC;
36
+ const chunkDurSec = CHUNK_DURATION_SEC;
37
+ function getAheadSeconds(currentChunkIndex, chunks) {
38
+ let aheadSec = 0;
39
+ for (let i = currentChunkIndex; i < chunks.length; i++) {
40
+ const chunk = chunks[i];
41
+ if (!chunk || chunk.state !== "ready") break;
42
+ aheadSec += chunkDurSec;
43
+ }
44
+ return aheadSec;
45
+ }
46
+ function getHealth(currentChunkIndex, chunks) {
47
+ const ahead = getAheadSeconds(currentChunkIndex, chunks);
48
+ if (ahead >= healthySec) return "healthy";
49
+ if (ahead >= lowSec) return "low";
50
+ if (ahead >= criticalSec) return "critical";
51
+ return "empty";
52
+ }
53
+ function shouldEnterBuffering(currentChunkIndex, chunks) {
54
+ const ahead = getAheadSeconds(currentChunkIndex, chunks);
55
+ if (ahead >= criticalSec) return false;
56
+ const nextChunk = chunks[currentChunkIndex + 1];
57
+ if (nextChunk && nextChunk.state === "ready") return false;
58
+ const currentChunk = chunks[currentChunkIndex];
59
+ if (!currentChunk || currentChunk.state !== "ready") return true;
60
+ return ahead < criticalSec;
61
+ }
62
+ function shouldExitBuffering(currentChunkIndex, chunks) {
63
+ const currentChunk = chunks[currentChunkIndex];
64
+ if (!currentChunk || currentChunk.state !== "ready") return false;
65
+ const ahead = getAheadSeconds(currentChunkIndex, chunks);
66
+ if (ahead >= resumeSec) return true;
67
+ const nextChunk = chunks[currentChunkIndex + 1];
68
+ if (nextChunk && nextChunk.state === "ready") return true;
69
+ const allReady = chunks.every((c) => c.state === "ready" || c.state === "skipped");
70
+ if (allReady) return true;
71
+ return false;
72
+ }
73
+ return {
74
+ getHealth,
75
+ getAheadSeconds,
76
+ shouldEnterBuffering,
77
+ shouldExitBuffering
78
+ };
79
+ }
80
+
81
+ // src/stretcher/transition-timing.ts
82
+ var TRANSITION_MARGIN_MS = 50;
83
+ function calcTransitionDelay(startTime, currentTime) {
84
+ return Math.max(0, (startTime - currentTime) * 1e3 + TRANSITION_MARGIN_MS);
85
+ }
86
+
87
+ // src/stretcher/chunk-player.ts
88
+ var CURVE_LENGTH = 256;
89
+ function createEqualPowerCurve(fadeIn) {
90
+ const curve = new Float32Array(CURVE_LENGTH);
91
+ for (let i = 0; i < CURVE_LENGTH; i++) {
92
+ const t = i / (CURVE_LENGTH - 1);
93
+ curve[i] = fadeIn ? Math.sin(t * Math.PI / 2) : Math.cos(t * Math.PI / 2);
94
+ }
95
+ return curve;
96
+ }
97
+ var fadeInCurve = createEqualPowerCurve(true);
98
+ var fadeOutCurve = createEqualPowerCurve(false);
99
+ function createChunkPlayer(ctx, options) {
100
+ const destination = options.destination ?? ctx.destination;
101
+ const through = options.through ?? [];
102
+ const crossfadeSec = options.crossfadeSec ?? CROSSFADE_SEC;
103
+ let currentSource = null;
104
+ let nextSource = null;
105
+ let currentGain = null;
106
+ let nextGain = null;
107
+ let playStartCtxTime = 0;
108
+ let playStartOffset = 0;
109
+ let currentChunkDuration = 0;
110
+ let nextStartCtxTime = 0;
111
+ let paused = false;
112
+ let pausedPosition = 0;
113
+ let stopped = true;
114
+ let lookaheadTimer = null;
115
+ let transitionTimerId = null;
116
+ let onChunkEnded = null;
117
+ let onNeedNext = null;
118
+ let onTransition = null;
119
+ let disposed = false;
120
+ function connectToDestination(node) {
121
+ if (through.length > 0) {
122
+ node.connect(through[0]);
123
+ for (let i = 0; i < through.length - 1; i++) {
124
+ through[i].connect(through[i + 1]);
125
+ }
126
+ through[through.length - 1].connect(destination);
127
+ } else {
128
+ node.connect(destination);
129
+ }
130
+ }
131
+ function createSourceFromBuffer(buffer, gain) {
132
+ const src = ctx.createBufferSource();
133
+ src.buffer = buffer;
134
+ src.connect(gain);
135
+ connectToDestination(gain);
136
+ return src;
137
+ }
138
+ function stopCurrentSource() {
139
+ if (currentSource) {
140
+ currentSource.onended = null;
141
+ try {
142
+ currentSource.stop();
143
+ } catch {
144
+ }
145
+ currentSource.disconnect();
146
+ currentSource = null;
147
+ }
148
+ if (currentGain) {
149
+ currentGain.disconnect();
150
+ currentGain = null;
151
+ }
152
+ }
153
+ function stopNextSource() {
154
+ if (nextSource) {
155
+ nextSource.onended = null;
156
+ try {
157
+ nextSource.stop();
158
+ } catch {
159
+ }
160
+ nextSource.disconnect();
161
+ nextSource = null;
162
+ }
163
+ if (nextGain) {
164
+ nextGain.disconnect();
165
+ nextGain = null;
166
+ }
167
+ }
168
+ function startLookahead() {
169
+ if (lookaheadTimer !== null) return;
170
+ lookaheadTimer = setInterval(() => {
171
+ if (paused || stopped || disposed) return;
172
+ const pos = getElapsedInChunk();
173
+ const remaining = currentChunkDuration - pos;
174
+ if (remaining <= LOOKAHEAD_THRESHOLD_SEC && !nextSource) {
175
+ onNeedNext?.();
176
+ }
177
+ }, LOOKAHEAD_INTERVAL_MS);
178
+ }
179
+ function stopLookahead() {
180
+ if (lookaheadTimer !== null) {
181
+ clearInterval(lookaheadTimer);
182
+ lookaheadTimer = null;
183
+ }
184
+ }
185
+ function cancelTransition() {
186
+ if (transitionTimerId !== null) {
187
+ clearTimeout(transitionTimerId);
188
+ transitionTimerId = null;
189
+ }
190
+ }
191
+ function doTransition(buffer, startCtxTime) {
192
+ stopCurrentSource();
193
+ currentSource = nextSource;
194
+ currentGain = nextGain;
195
+ nextSource = null;
196
+ nextGain = null;
197
+ currentChunkDuration = buffer.duration;
198
+ playStartOffset = 0;
199
+ playStartCtxTime = startCtxTime;
200
+ if (currentSource) {
201
+ currentSource.onended = handleCurrentSourceEnded;
202
+ }
203
+ onTransition?.();
204
+ }
205
+ function handleCurrentSourceEnded() {
206
+ if (disposed || paused || stopped) return;
207
+ if (nextSource) {
208
+ const buf = nextSource.buffer;
209
+ if (!buf) {
210
+ onChunkEnded?.();
211
+ return;
212
+ }
213
+ cancelTransition();
214
+ doTransition(buf, nextStartCtxTime);
215
+ } else {
216
+ onChunkEnded?.();
217
+ }
218
+ }
219
+ function getElapsedInChunk() {
220
+ if (paused) return pausedPosition;
221
+ if (stopped) return 0;
222
+ return ctx.currentTime - playStartCtxTime + playStartOffset;
223
+ }
224
+ function playChunk(buffer, _startTime, offsetInChunk = 0, skipFadeIn = false) {
225
+ cancelTransition();
226
+ stopCurrentSource();
227
+ stopNextSource();
228
+ currentGain = ctx.createGain();
229
+ currentSource = createSourceFromBuffer(buffer, currentGain);
230
+ currentChunkDuration = buffer.duration;
231
+ playStartOffset = offsetInChunk;
232
+ playStartCtxTime = ctx.currentTime;
233
+ paused = false;
234
+ stopped = false;
235
+ currentSource.onended = handleCurrentSourceEnded;
236
+ currentSource.start(0, offsetInChunk);
237
+ if (crossfadeSec > 0 && !skipFadeIn) {
238
+ currentGain.gain.setValueCurveAtTime(fadeInCurve, ctx.currentTime, crossfadeSec);
239
+ }
240
+ startLookahead();
241
+ }
242
+ function scheduleNext(buffer, startTime) {
243
+ if (disposed) return;
244
+ stopNextSource();
245
+ nextGain = ctx.createGain();
246
+ nextSource = createSourceFromBuffer(buffer, nextGain);
247
+ nextSource.onended = handleCurrentSourceEnded;
248
+ nextStartCtxTime = startTime - crossfadeSec;
249
+ nextSource.start(nextStartCtxTime);
250
+ if (crossfadeSec > 0 && currentGain) {
251
+ currentGain.gain.setValueCurveAtTime(fadeOutCurve, nextStartCtxTime, crossfadeSec);
252
+ nextGain.gain.setValueCurveAtTime(fadeInCurve, nextStartCtxTime, crossfadeSec);
253
+ }
254
+ const transitionDelay = calcTransitionDelay(startTime, ctx.currentTime);
255
+ cancelTransition();
256
+ transitionTimerId = setTimeout(() => {
257
+ transitionTimerId = null;
258
+ if (disposed || !nextSource) return;
259
+ doTransition(buffer, nextStartCtxTime);
260
+ }, transitionDelay);
261
+ }
262
+ function handleSeek(buffer, offsetInChunk) {
263
+ playChunk(buffer, 0, offsetInChunk);
264
+ }
265
+ function pause() {
266
+ if (paused || stopped || disposed) return;
267
+ pausedPosition = getElapsedInChunk();
268
+ paused = true;
269
+ cancelTransition();
270
+ stopCurrentSource();
271
+ stopNextSource();
272
+ stopLookahead();
273
+ }
274
+ function resume() {
275
+ if (!paused || disposed) return;
276
+ }
277
+ function stop() {
278
+ if (stopped || disposed) return;
279
+ stopped = true;
280
+ paused = false;
281
+ pausedPosition = 0;
282
+ cancelTransition();
283
+ stopCurrentSource();
284
+ stopNextSource();
285
+ stopLookahead();
286
+ }
287
+ function getCurrentPosition() {
288
+ return getElapsedInChunk();
289
+ }
290
+ function hasNextScheduled() {
291
+ return nextSource !== null;
292
+ }
293
+ return {
294
+ playChunk,
295
+ scheduleNext,
296
+ hasNextScheduled,
297
+ handleSeek,
298
+ pause,
299
+ resume,
300
+ stop,
301
+ getCurrentPosition,
302
+ setOnChunkEnded(callback) {
303
+ onChunkEnded = callback;
304
+ },
305
+ setOnNeedNext(callback) {
306
+ onNeedNext = callback;
307
+ },
308
+ setOnTransition(callback) {
309
+ onTransition = callback;
310
+ },
311
+ dispose() {
312
+ if (disposed) return;
313
+ disposed = true;
314
+ cancelTransition();
315
+ stopCurrentSource();
316
+ stopNextSource();
317
+ stopLookahead();
318
+ }
319
+ };
320
+ }
28
321
 
29
322
  // src/stretcher/chunk-splitter.ts
30
323
  function splitIntoChunks(totalSamples, sampleRate, chunkDurationSec = CHUNK_DURATION_SEC, overlapSec = OVERLAP_SEC) {
@@ -86,9 +379,7 @@ function extractChunkData(buffer, chunk) {
86
379
  for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
87
380
  const fullChannel = buffer.getChannelData(ch);
88
381
  const chunkData = new Float32Array(length);
89
- chunkData.set(
90
- fullChannel.subarray(chunk.inputStartSample, chunk.inputEndSample)
91
- );
382
+ chunkData.set(fullChannel.subarray(chunk.inputStartSample, chunk.inputEndSample));
92
383
  channels.push(chunkData);
93
384
  }
94
385
  return channels;
@@ -109,370 +400,273 @@ function getChunkIndexForTime(chunks, timeSeconds, sampleRate) {
109
400
  return getChunkIndexForSample(chunks, sample);
110
401
  }
111
402
 
112
- // src/stretcher/worker-inline.ts
113
- function getWorkerCode() {
114
- return `"use strict";
115
-
116
- var FRAME_SIZE = ${WSOLA_FRAME_SIZE};
117
- var HOP_SIZE = ${WSOLA_HOP_SIZE};
118
- var TOLERANCE = ${WSOLA_TOLERANCE};
119
-
120
- function createHannWindow(size) {
121
- var w = new Float32Array(size);
122
- for (var i = 0; i < size; i++) {
123
- w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)));
124
- }
125
- return w;
126
- }
127
-
128
- function findBestOffset(ref, search, overlapSize, maxOffset) {
129
- var bestOffset = 0;
130
- var bestCorr = -Infinity;
131
- var searchLen = search.length;
132
- var refLen = ref.length;
133
- var len = Math.min(overlapSize, refLen);
134
-
135
- for (var offset = 0; offset <= maxOffset; offset++) {
136
- if (offset + len > searchLen) break;
137
- var corr = 0;
138
- var normRef = 0;
139
- var normSearch = 0;
140
- for (var i = 0; i < len; i++) {
141
- var r = ref[i];
142
- var s = search[offset + i];
143
- corr += r * s;
144
- normRef += r * r;
145
- normSearch += s * s;
146
- }
147
- var denom = Math.sqrt(normRef * normSearch);
148
- var ncc = denom > 1e-10 ? corr / denom : 0;
149
- if (ncc > bestCorr) {
150
- bestCorr = ncc;
151
- bestOffset = offset;
152
- }
403
+ // src/stretcher/priority-queue.ts
404
+ function createPriorityQueue(compareFn) {
405
+ const heap = [];
406
+ function parent(i) {
407
+ return Math.floor((i - 1) / 2);
153
408
  }
154
- return bestOffset;
155
- }
156
-
157
- function wsolaTimeStretch(channels, tempo, sampleRate) {
158
- if (channels.length === 0) {
159
- return { output: [], length: 0 };
409
+ function left(i) {
410
+ return 2 * i + 1;
160
411
  }
161
-
162
- var inputLength = channels[0].length;
163
- if (inputLength === 0) {
164
- return { output: channels.map(function() { return new Float32Array(0); }), length: 0 };
412
+ function right(i) {
413
+ return 2 * i + 2;
165
414
  }
166
-
167
- var synthesisHop = HOP_SIZE;
168
- var analysisHop = Math.round(HOP_SIZE * tempo);
169
- var numFrames = Math.floor((inputLength - FRAME_SIZE) / analysisHop) + 1;
170
-
171
- if (numFrames <= 0) {
172
- return {
173
- output: channels.map(function(ch) { return new Float32Array(ch); }),
174
- length: inputLength
175
- };
415
+ function swap(i, j) {
416
+ const tmp = heap[i];
417
+ heap[i] = heap[j];
418
+ heap[j] = tmp;
176
419
  }
177
-
178
- var estimatedOutputLength = (numFrames - 1) * synthesisHop + FRAME_SIZE;
179
- var outputChannels = channels.map(function() {
180
- return new Float32Array(estimatedOutputLength);
181
- });
182
- var windowFunc = createHannWindow(FRAME_SIZE);
183
- var normBuffer = new Float32Array(estimatedOutputLength);
184
-
185
- var prevOutputFrame = channels.map(function() {
186
- return new Float32Array(FRAME_SIZE);
187
- });
188
-
189
- var inputPos = 0;
190
- var outputPos = 0;
191
- var actualOutputLength = 0;
192
-
193
- for (var frame = 0; frame < numFrames; frame++) {
194
- if (cancelled) return null;
195
- if (inputPos + FRAME_SIZE > inputLength) break;
196
-
197
- var actualInputPos = inputPos;
198
-
199
- if (frame > 0 && TOLERANCE > 0) {
200
- var searchStart = Math.max(0, inputPos - TOLERANCE);
201
- var searchEnd = Math.min(inputLength - FRAME_SIZE, inputPos + TOLERANCE);
202
- var searchRange = searchEnd - searchStart;
203
-
204
- if (searchRange > 0) {
205
- var refChannel = prevOutputFrame[0];
206
- var inputChannel = channels[0];
207
-
208
- var overlapStart = FRAME_SIZE - synthesisHop;
209
- var overlapSize = Math.min(synthesisHop, FRAME_SIZE - overlapStart);
210
-
211
- var refSlice = refChannel.subarray(overlapStart, overlapStart + overlapSize);
212
- var searchSlice = inputChannel.subarray(searchStart, searchEnd + overlapSize);
213
-
214
- var bestOffset = findBestOffset(
215
- refSlice, searchSlice, overlapSize,
216
- Math.min(searchRange, searchSlice.length - overlapSize)
217
- );
218
-
219
- actualInputPos = searchStart + bestOffset;
420
+ function siftUp(i) {
421
+ while (i > 0) {
422
+ const p = parent(i);
423
+ if (compareFn(heap[i], heap[p]) < 0) {
424
+ swap(i, p);
425
+ i = p;
426
+ } else {
427
+ break;
220
428
  }
221
429
  }
222
-
223
- for (var ch = 0; ch < channels.length; ch++) {
224
- var input = channels[ch];
225
- var output = outputChannels[ch];
226
- var prevFrame = prevOutputFrame[ch];
227
- for (var i = 0; i < FRAME_SIZE; i++) {
228
- var inIdx = actualInputPos + i;
229
- if (inIdx >= inputLength) break;
230
- var outIdx = outputPos + i;
231
- if (outIdx >= estimatedOutputLength) break;
232
- var sample = input[inIdx];
233
- output[outIdx] += sample * windowFunc[i];
234
- prevFrame[i] = sample;
430
+ }
431
+ function siftDown(i) {
432
+ const n = heap.length;
433
+ while (true) {
434
+ let smallest = i;
435
+ const l = left(i);
436
+ const r = right(i);
437
+ if (l < n && compareFn(heap[l], heap[smallest]) < 0) {
438
+ smallest = l;
439
+ }
440
+ if (r < n && compareFn(heap[r], heap[smallest]) < 0) {
441
+ smallest = r;
442
+ }
443
+ if (smallest !== i) {
444
+ swap(i, smallest);
445
+ i = smallest;
446
+ } else {
447
+ break;
235
448
  }
236
449
  }
237
-
238
- for (var i = 0; i < FRAME_SIZE; i++) {
239
- var outIdx = outputPos + i;
240
- if (outIdx >= estimatedOutputLength) break;
241
- normBuffer[outIdx] += windowFunc[i];
242
- }
243
-
244
- inputPos += analysisHop;
245
- outputPos += synthesisHop;
246
- actualOutputLength = Math.min(outputPos + FRAME_SIZE, estimatedOutputLength);
247
450
  }
248
-
249
- for (var ch = 0; ch < outputChannels.length; ch++) {
250
- var output = outputChannels[ch];
251
- for (var i = 0; i < actualOutputLength; i++) {
252
- var norm = normBuffer[i];
253
- if (norm > 1e-8) {
254
- output[i] /= norm;
255
- }
451
+ function heapify() {
452
+ for (let i = Math.floor(heap.length / 2) - 1; i >= 0; i--) {
453
+ siftDown(i);
256
454
  }
257
455
  }
258
-
259
- var trimmedOutput = outputChannels.map(function(ch) {
260
- return ch.slice(0, actualOutputLength);
261
- });
262
-
263
- return { output: trimmedOutput, length: actualOutputLength };
264
- }
265
-
266
- var cancelled = false;
267
-
268
- self.onmessage = function(e) {
269
- var msg = e.data;
270
- if (msg.type === "cancel") {
271
- cancelled = true;
272
- return;
273
- }
274
- if (msg.type === "convert") {
275
- cancelled = false;
276
- try {
277
- var result = wsolaTimeStretch(msg.inputData, msg.tempo, msg.sampleRate);
278
- if (cancelled || result === null) {
279
- self.postMessage({ type: "cancelled", chunkIndex: msg.chunkIndex });
456
+ return {
457
+ enqueue(item) {
458
+ heap.push(item);
459
+ siftUp(heap.length - 1);
460
+ },
461
+ dequeue() {
462
+ if (heap.length === 0) return void 0;
463
+ const min = heap[0];
464
+ const last = heap.pop();
465
+ if (heap.length > 0) {
466
+ heap[0] = last;
467
+ siftDown(0);
468
+ }
469
+ return min;
470
+ },
471
+ peek() {
472
+ return heap[0];
473
+ },
474
+ remove(predicate) {
475
+ const idx = heap.findIndex(predicate);
476
+ if (idx === -1) return false;
477
+ if (idx === heap.length - 1) {
478
+ heap.pop();
280
479
  } else {
281
- self.postMessage(
282
- { type: "result", chunkIndex: msg.chunkIndex, outputData: result.output, outputLength: result.length },
283
- result.output.map(function(ch) { return ch.buffer; })
284
- );
480
+ heap[idx] = heap.pop();
481
+ siftDown(idx);
482
+ siftUp(idx);
285
483
  }
286
- } catch (err) {
287
- self.postMessage({ type: "error", chunkIndex: msg.chunkIndex, error: String(err) });
484
+ return true;
485
+ },
486
+ rebuild() {
487
+ heapify();
488
+ },
489
+ clear() {
490
+ heap.length = 0;
491
+ },
492
+ size() {
493
+ return heap.length;
494
+ },
495
+ toArray() {
496
+ return [...heap];
288
497
  }
289
- }
290
- };
291
- `;
292
- }
293
- function createWorkerURL() {
294
- const code = getWorkerCode();
295
- const blob = new Blob([code], { type: "application/javascript" });
296
- return URL.createObjectURL(blob);
297
- }
298
- function revokeWorkerURL(url) {
299
- URL.revokeObjectURL(url);
498
+ };
300
499
  }
301
500
 
302
- // src/stretcher/worker-manager.ts
303
- function createWorkerManager(onResult, onError, maxCrashes = MAX_WORKER_CRASHES, poolSize = WORKER_POOL_SIZE, onAllDead) {
304
- let workerURL = null;
305
- let terminated = false;
306
- const postTimes = /* @__PURE__ */ new Map();
307
- const slots = [];
308
- function ensureWorkerURL() {
309
- if (!workerURL) {
310
- workerURL = createWorkerURL();
311
- }
312
- return workerURL;
313
- }
314
- function isAllDead() {
315
- return slots.every((s) => s.worker === null);
501
+ // src/stretcher/conversion-scheduler.ts
502
+ function createConversionScheduler(chunks, workerManager, extractChunkData2, sampleRate, tempo, options, onChunkReady, onChunkFailed) {
503
+ const forwardWeight = options?.forwardWeight ?? PRIORITY_FORWARD_WEIGHT;
504
+ const backwardWeight = options?.backwardWeight ?? PRIORITY_BACKWARD_WEIGHT;
505
+ const cancelDistThreshold = options?.cancelDistanceThreshold ?? CANCEL_DISTANCE_THRESHOLD;
506
+ const keepAhead = options?.keepAheadChunks ?? Math.max(KEEP_AHEAD_CHUNKS, Math.ceil(KEEP_AHEAD_SECONDS / CHUNK_DURATION_SEC));
507
+ const keepBehind = options?.keepBehindChunks ?? Math.max(KEEP_BEHIND_CHUNKS, Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC));
508
+ function isInActiveWindow(chunkIndex, playheadIndex) {
509
+ const dist = chunkIndex - playheadIndex;
510
+ return dist <= keepAhead && dist >= -keepBehind;
316
511
  }
317
- function spawnWorkerForSlot(slot) {
318
- if (terminated) return;
319
- const url = ensureWorkerURL();
320
- let worker;
321
- try {
322
- worker = new Worker(url);
323
- } catch {
324
- slot.worker = null;
325
- return;
512
+ let currentTempo = tempo;
513
+ let currentChunkIdx = 0;
514
+ let previousTempoCache = null;
515
+ let disposed = false;
516
+ const queue = createPriorityQueue((a, b) => a.priority - b.priority);
517
+ function calcPriority(chunkIndex, playheadIndex) {
518
+ const distance = chunkIndex - playheadIndex;
519
+ if (distance >= 0) {
520
+ return distance * forwardWeight;
326
521
  }
327
- worker.onmessage = (e) => {
328
- const response = e.data;
329
- if (response.type === "result" || response.type === "cancelled") {
330
- slot.busy = false;
331
- slot.currentChunkIndex = null;
522
+ return Math.abs(distance) * backwardWeight;
523
+ }
524
+ function updatePriorities(playheadIndex) {
525
+ currentChunkIdx = playheadIndex;
526
+ queue.clear();
527
+ for (const chunk of chunks) {
528
+ if (chunk.state === "pending" || chunk.state === "queued" || chunk.state === "failed") {
529
+ chunk.priority = calcPriority(chunk.index, playheadIndex);
530
+ chunk.state = "queued";
531
+ queue.enqueue(chunk);
532
+ } else if (chunk.state === "evicted" && isInActiveWindow(chunk.index, playheadIndex)) {
533
+ chunk.state = "queued";
534
+ chunk.retryCount = 0;
535
+ chunk.priority = calcPriority(chunk.index, playheadIndex);
536
+ queue.enqueue(chunk);
332
537
  }
333
- if (response.type === "error") {
334
- slot.busy = false;
335
- slot.currentChunkIndex = null;
336
- onError(response);
337
- return;
538
+ }
539
+ for (const chunk of chunks) {
540
+ if (chunk.state === "converting") {
541
+ const dist = Math.abs(chunk.index - playheadIndex);
542
+ if (dist > cancelDistThreshold) {
543
+ workerManager.cancelChunk(chunk.index);
544
+ }
338
545
  }
339
- onResult(response);
340
- };
341
- worker.onerror = (e) => {
342
- e.preventDefault();
343
- slot.busy = false;
344
- const failedChunkIndex = slot.currentChunkIndex;
345
- slot.currentChunkIndex = null;
346
- slot.crashCount++;
347
- if (failedChunkIndex !== null) {
348
- onError({
349
- type: "error",
350
- chunkIndex: failedChunkIndex,
351
- error: `Worker crashed: ${e.message}`
352
- });
546
+ }
547
+ }
548
+ function dispatchNext() {
549
+ if (disposed) return;
550
+ while (workerManager.hasCapacity()) {
551
+ let nextChunk = queue.dequeue();
552
+ while (nextChunk && nextChunk.state === "ready") {
553
+ nextChunk = queue.dequeue();
353
554
  }
354
- if (slot.worker) {
355
- slot.worker.onmessage = null;
356
- slot.worker.onerror = null;
357
- slot.worker.terminate();
358
- slot.worker = null;
555
+ if (!nextChunk) return;
556
+ if (nextChunk.state !== "queued" && nextChunk.state !== "pending" && nextChunk.state !== "failed") {
557
+ continue;
359
558
  }
360
- if (slot.crashCount < maxCrashes) {
361
- spawnWorkerForSlot(slot);
362
- } else {
363
- onError({
364
- type: "error",
365
- chunkIndex: failedChunkIndex ?? -1,
366
- error: `Worker crashed ${slot.crashCount} times, giving up`
367
- });
368
- if (isAllDead()) {
369
- onAllDead?.();
559
+ nextChunk.state = "converting";
560
+ const data = extractChunkData2(nextChunk.index);
561
+ workerManager.postConvert(nextChunk.index, data, currentTempo, sampleRate);
562
+ }
563
+ }
564
+ function handleResult(chunkIndex, outputData, outputLength) {
565
+ if (disposed) return;
566
+ const chunk = chunks[chunkIndex];
567
+ if (!chunk) return;
568
+ if (chunk.state !== "converting") {
569
+ dispatchNext();
570
+ return;
571
+ }
572
+ chunk.state = "ready";
573
+ chunk.outputBuffer = outputData;
574
+ chunk.outputLength = outputLength;
575
+ onChunkReady?.(chunkIndex);
576
+ dispatchNext();
577
+ }
578
+ function handleError(chunkIndex, error) {
579
+ if (disposed) return;
580
+ const chunk = chunks[chunkIndex];
581
+ if (!chunk) return;
582
+ chunk.retryCount++;
583
+ if (chunk.retryCount < MAX_CHUNK_RETRIES) {
584
+ chunk.state = "queued";
585
+ chunk.priority = calcPriority(chunk.index, currentChunkIdx);
586
+ queue.enqueue(chunk);
587
+ } else {
588
+ chunk.state = "failed";
589
+ onChunkFailed?.(chunkIndex, error);
590
+ }
591
+ dispatchNext();
592
+ }
593
+ function handleSeek(newChunkIndex) {
594
+ for (const chunk of chunks) {
595
+ if (chunk.state === "converting") {
596
+ const dist = Math.abs(chunk.index - newChunkIndex);
597
+ if (dist > cancelDistThreshold) {
598
+ workerManager.cancelChunk(chunk.index);
370
599
  }
371
600
  }
372
- };
373
- slot.worker = worker;
601
+ }
602
+ updatePriorities(newChunkIndex);
603
+ dispatchNext();
374
604
  }
375
- for (let i = 0; i < poolSize; i++) {
376
- const slot = {
377
- worker: null,
378
- busy: false,
379
- currentChunkIndex: null,
380
- crashCount: 0
605
+ function handleTempoChange(newTempo) {
606
+ previousTempoCache = {
607
+ tempo: currentTempo,
608
+ chunks: chunks.map((c) => {
609
+ if (isInActiveWindow(c.index, currentChunkIdx) && c.outputBuffer) {
610
+ return { outputBuffer: c.outputBuffer, outputLength: c.outputLength };
611
+ }
612
+ return { outputBuffer: null, outputLength: 0 };
613
+ })
381
614
  };
382
- slots.push(slot);
383
- spawnWorkerForSlot(slot);
384
- }
385
- if (isAllDead()) {
386
- onAllDead?.();
387
- }
388
- function findFreeSlot() {
389
- for (const slot of slots) {
390
- if (!slot.busy && slot.worker) {
391
- return slot;
615
+ currentTempo = newTempo;
616
+ workerManager.cancelCurrent();
617
+ for (const chunk of chunks) {
618
+ if (chunk.state === "evicted") continue;
619
+ if (isInActiveWindow(chunk.index, currentChunkIdx)) {
620
+ chunk.outputBuffer = null;
621
+ chunk.outputLength = 0;
622
+ chunk.state = "pending";
623
+ chunk.retryCount = 0;
624
+ } else {
625
+ chunk.outputBuffer = null;
626
+ chunk.outputLength = 0;
627
+ chunk.state = "evicted";
392
628
  }
393
629
  }
394
- return null;
630
+ updatePriorities(currentChunkIdx);
631
+ dispatchNext();
395
632
  }
396
- function findSlotByChunk(chunkIndex) {
397
- for (const slot of slots) {
398
- if (slot.busy && slot.currentChunkIndex === chunkIndex) {
399
- return slot;
633
+ function restorePreviousTempo() {
634
+ if (!previousTempoCache) return false;
635
+ currentTempo = previousTempoCache.tempo;
636
+ for (let i = 0; i < chunks.length; i++) {
637
+ const cached = previousTempoCache.chunks[i];
638
+ const chunk = chunks[i];
639
+ if (chunk && cached?.outputBuffer) {
640
+ chunk.outputBuffer = cached.outputBuffer;
641
+ chunk.outputLength = cached.outputLength;
642
+ chunk.state = "ready";
400
643
  }
401
644
  }
402
- return null;
645
+ previousTempoCache = null;
646
+ workerManager.cancelCurrent();
647
+ updatePriorities(currentChunkIdx);
648
+ dispatchNext();
649
+ return true;
650
+ }
651
+ function start(playheadIndex) {
652
+ updatePriorities(playheadIndex);
653
+ dispatchNext();
403
654
  }
404
655
  return {
405
- postConvert(chunkIndex, inputData, tempo, sampleRate) {
406
- if (terminated) return;
407
- const slot = findFreeSlot();
408
- if (!slot || !slot.worker) return;
409
- slot.busy = true;
410
- slot.currentChunkIndex = chunkIndex;
411
- postTimes.set(chunkIndex, performance.now());
412
- const transferables = inputData.map((ch) => ch.buffer);
413
- slot.worker.postMessage(
414
- { type: "convert", chunkIndex, inputData, tempo, sampleRate },
415
- transferables
416
- );
417
- },
418
- cancelCurrent() {
419
- if (terminated) return;
420
- for (const slot of slots) {
421
- if (slot.busy && slot.worker && slot.currentChunkIndex !== null) {
422
- slot.worker.postMessage({ type: "cancel", chunkIndex: slot.currentChunkIndex });
423
- }
424
- }
425
- },
426
- cancelChunk(chunkIndex) {
427
- if (terminated) return;
428
- const slot = findSlotByChunk(chunkIndex);
429
- if (slot && slot.worker) {
430
- slot.worker.postMessage({ type: "cancel", chunkIndex });
431
- }
432
- },
433
- isBusy() {
434
- return slots.every((s) => s.busy || !s.worker);
435
- },
436
- hasCapacity() {
437
- return findFreeSlot() !== null;
438
- },
439
- getCurrentChunkIndex() {
440
- for (const slot of slots) {
441
- if (slot.busy && slot.currentChunkIndex !== null) {
442
- return slot.currentChunkIndex;
443
- }
444
- }
445
- return null;
446
- },
447
- getLastPostTime() {
448
- let latest = null;
449
- for (const t of postTimes.values()) {
450
- if (latest === null || t > latest) {
451
- latest = t;
452
- }
453
- }
454
- return latest;
455
- },
456
- getPostTimeForChunk(chunkIndex) {
457
- return postTimes.get(chunkIndex) ?? null;
656
+ start,
657
+ updatePriorities,
658
+ handleSeek,
659
+ handleTempoChange,
660
+ restorePreviousTempo,
661
+ dispatchNext,
662
+ getChunks: () => chunks,
663
+ dispose() {
664
+ disposed = true;
665
+ queue.clear();
458
666
  },
459
- terminate() {
460
- if (terminated) return;
461
- terminated = true;
462
- for (const slot of slots) {
463
- if (slot.worker) {
464
- slot.worker.onmessage = null;
465
- slot.worker.onerror = null;
466
- slot.worker.terminate();
467
- slot.worker = null;
468
- }
469
- }
470
- if (workerURL) {
471
- revokeWorkerURL(workerURL);
472
- workerURL = null;
473
- }
474
- postTimes.clear();
475
- }
667
+ // Expose internal handlers for the engine to wire up
668
+ _handleResult: handleResult,
669
+ _handleError: handleError
476
670
  };
477
671
  }
478
672
 
@@ -519,6 +713,13 @@ function wsolaTimeStretch(channels, tempo, _sampleRate, frameSize = WSOLA_FRAME_
519
713
  if (inputLength === 0) {
520
714
  return { output: channels.map(() => new Float32Array(0)), length: 0 };
521
715
  }
716
+ const TEMPO_IDENTITY_EPSILON = 1e-3;
717
+ if (Math.abs(tempo - 1) < TEMPO_IDENTITY_EPSILON) {
718
+ return {
719
+ output: channels.map((ch) => new Float32Array(ch)),
720
+ length: inputLength
721
+ };
722
+ }
522
723
  const synthesisHop = hopSize;
523
724
  const analysisHop = Math.round(hopSize * tempo);
524
725
  const numFrames = Math.floor((inputLength - frameSize) / analysisHop) + 1;
@@ -529,9 +730,7 @@ function wsolaTimeStretch(channels, tempo, _sampleRate, frameSize = WSOLA_FRAME_
529
730
  };
530
731
  }
531
732
  const estimatedOutputLength = (numFrames - 1) * synthesisHop + frameSize;
532
- const outputChannels = channels.map(
533
- () => new Float32Array(estimatedOutputLength)
534
- );
733
+ const outputChannels = channels.map(() => new Float32Array(estimatedOutputLength));
535
734
  const windowFunc = createHannWindow(frameSize);
536
735
  const normBuffer = new Float32Array(estimatedOutputLength);
537
736
  const prevOutputFrame = channels.map(() => new Float32Array(frameSize));
@@ -550,14 +749,8 @@ function wsolaTimeStretch(channels, tempo, _sampleRate, frameSize = WSOLA_FRAME_
550
749
  const inputChannel = channels[0];
551
750
  const overlapStart = frameSize - synthesisHop;
552
751
  const overlapSize = Math.min(synthesisHop, frameSize - overlapStart);
553
- const refSlice = refChannel.subarray(
554
- overlapStart,
555
- overlapStart + overlapSize
556
- );
557
- const searchSlice = inputChannel.subarray(
558
- searchStart,
559
- searchEnd + overlapSize
560
- );
752
+ const refSlice = refChannel.subarray(overlapStart, overlapStart + overlapSize);
753
+ const searchSlice = inputChannel.subarray(searchStart, searchEnd + overlapSize);
561
754
  const bestOffset = findBestOffset(
562
755
  refSlice,
563
756
  searchSlice,
@@ -578,7 +771,7 @@ function wsolaTimeStretch(channels, tempo, _sampleRate, frameSize = WSOLA_FRAME_
578
771
  if (outIdx >= estimatedOutputLength) break;
579
772
  const sample = input[inIdx];
580
773
  output[outIdx] += sample * windowFunc[i];
581
- prevFrame[i] = sample;
774
+ prevFrame[i] = sample * windowFunc[i];
582
775
  }
583
776
  }
584
777
  for (let i = 0; i < frameSize; i++) {
@@ -599,9 +792,7 @@ function wsolaTimeStretch(channels, tempo, _sampleRate, frameSize = WSOLA_FRAME_
599
792
  }
600
793
  }
601
794
  }
602
- const trimmedOutput = outputChannels.map(
603
- (ch) => ch.subarray(0, actualOutputLength)
604
- );
795
+ const trimmedOutput = outputChannels.map((ch) => ch.subarray(0, actualOutputLength));
605
796
  return { output: trimmedOutput, length: actualOutputLength };
606
797
  }
607
798
 
@@ -610,7 +801,7 @@ function createMainThreadProcessor(onResult, onError) {
610
801
  let terminated = false;
611
802
  const postTimes = /* @__PURE__ */ new Map();
612
803
  let currentChunkIndex = null;
613
- let cancelledChunks = /* @__PURE__ */ new Set();
804
+ const cancelledChunks = /* @__PURE__ */ new Set();
614
805
  let busy = false;
615
806
  return {
616
807
  postConvert(chunkIndex, inputData, tempo, sampleRate) {
@@ -683,569 +874,412 @@ function createMainThreadProcessor(onResult, onError) {
683
874
  }
684
875
  return latest;
685
876
  },
686
- getPostTimeForChunk(chunkIndex) {
687
- return postTimes.get(chunkIndex) ?? null;
688
- },
689
- terminate() {
690
- if (terminated) return;
691
- terminated = true;
692
- cancelledChunks.clear();
693
- postTimes.clear();
694
- }
695
- };
696
- }
697
-
698
- // src/stretcher/priority-queue.ts
699
- function createPriorityQueue(compareFn) {
700
- const heap = [];
701
- function parent(i) {
702
- return Math.floor((i - 1) / 2);
703
- }
704
- function left(i) {
705
- return 2 * i + 1;
706
- }
707
- function right(i) {
708
- return 2 * i + 2;
709
- }
710
- function swap(i, j) {
711
- const tmp = heap[i];
712
- heap[i] = heap[j];
713
- heap[j] = tmp;
714
- }
715
- function siftUp(i) {
716
- while (i > 0) {
717
- const p = parent(i);
718
- if (compareFn(heap[i], heap[p]) < 0) {
719
- swap(i, p);
720
- i = p;
721
- } else {
722
- break;
723
- }
724
- }
725
- }
726
- function siftDown(i) {
727
- const n = heap.length;
728
- while (true) {
729
- let smallest = i;
730
- const l = left(i);
731
- const r = right(i);
732
- if (l < n && compareFn(heap[l], heap[smallest]) < 0) {
733
- smallest = l;
734
- }
735
- if (r < n && compareFn(heap[r], heap[smallest]) < 0) {
736
- smallest = r;
737
- }
738
- if (smallest !== i) {
739
- swap(i, smallest);
740
- i = smallest;
741
- } else {
742
- break;
743
- }
744
- }
745
- }
746
- function heapify() {
747
- for (let i = Math.floor(heap.length / 2) - 1; i >= 0; i--) {
748
- siftDown(i);
749
- }
750
- }
751
- return {
752
- enqueue(item) {
753
- heap.push(item);
754
- siftUp(heap.length - 1);
755
- },
756
- dequeue() {
757
- if (heap.length === 0) return void 0;
758
- const min = heap[0];
759
- const last = heap.pop();
760
- if (heap.length > 0) {
761
- heap[0] = last;
762
- siftDown(0);
763
- }
764
- return min;
765
- },
766
- peek() {
767
- return heap[0];
768
- },
769
- remove(predicate) {
770
- const idx = heap.findIndex(predicate);
771
- if (idx === -1) return false;
772
- if (idx === heap.length - 1) {
773
- heap.pop();
774
- } else {
775
- heap[idx] = heap.pop();
776
- siftDown(idx);
777
- siftUp(idx);
778
- }
779
- return true;
780
- },
781
- rebuild() {
782
- heapify();
783
- },
784
- clear() {
785
- heap.length = 0;
786
- },
787
- size() {
788
- return heap.length;
877
+ getPostTimeForChunk(chunkIndex) {
878
+ return postTimes.get(chunkIndex) ?? null;
789
879
  },
790
- toArray() {
791
- return [...heap];
880
+ terminate() {
881
+ if (terminated) return;
882
+ terminated = true;
883
+ cancelledChunks.clear();
884
+ postTimes.clear();
792
885
  }
793
886
  };
794
887
  }
795
888
 
796
- // src/stretcher/conversion-scheduler.ts
797
- function createConversionScheduler(chunks, workerManager, extractChunkData2, sampleRate, tempo, options, onChunkReady, onChunkFailed) {
798
- const forwardWeight = options?.forwardWeight ?? PRIORITY_FORWARD_WEIGHT;
799
- const backwardWeight = options?.backwardWeight ?? PRIORITY_BACKWARD_WEIGHT;
800
- const cancelDistThreshold = options?.cancelDistanceThreshold ?? CANCEL_DISTANCE_THRESHOLD;
801
- const keepAhead = options?.keepAheadChunks ?? Math.max(
802
- KEEP_AHEAD_CHUNKS,
803
- Math.ceil(KEEP_AHEAD_SECONDS / CHUNK_DURATION_SEC)
804
- );
805
- const keepBehind = options?.keepBehindChunks ?? Math.max(
806
- KEEP_BEHIND_CHUNKS,
807
- Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC)
808
- );
809
- function isInActiveWindow(chunkIndex, playheadIndex) {
810
- const dist = chunkIndex - playheadIndex;
811
- return dist <= keepAhead && dist >= -keepBehind;
812
- }
813
- let currentTempo = tempo;
814
- let currentChunkIdx = 0;
815
- let previousTempoCache = null;
816
- let disposed = false;
817
- const queue = createPriorityQueue(
818
- (a, b) => a.priority - b.priority
819
- );
820
- function calcPriority(chunkIndex, playheadIndex) {
821
- const distance = chunkIndex - playheadIndex;
822
- if (distance >= 0) {
823
- return distance * forwardWeight;
824
- }
825
- return Math.abs(distance) * backwardWeight;
889
+ // src/stretcher/position-calc.ts
890
+ function calcPositionInOriginalBuffer(p) {
891
+ if (p.phase === "ended") return p.totalDuration;
892
+ if (p.phase === "waiting") return p.offset;
893
+ if ((p.phase === "buffering" || p.phase === "paused") && p.bufferingResumePosition !== null) {
894
+ return p.bufferingResumePosition;
895
+ }
896
+ if (!p.chunk) return 0;
897
+ const nominalStartSample = p.chunk.inputStartSample + p.chunk.overlapBefore;
898
+ const nominalStartSec = nominalStartSample / p.sampleRate;
899
+ const crossfadeOffset = p.chunk.overlapBefore > 0 ? p.crossfadeSec : 0;
900
+ const adjustedPosInChunk = Math.max(0, p.posInChunk - crossfadeOffset);
901
+ const posInOriginal = adjustedPosInChunk * p.currentTempo;
902
+ return Math.min(nominalStartSec + posInOriginal, p.totalDuration);
903
+ }
904
+
905
+ // src/stretcher/worker-inline.ts
906
+ function getWorkerCode() {
907
+ return `"use strict";
908
+
909
+ var FRAME_SIZE = ${WSOLA_FRAME_SIZE};
910
+ var HOP_SIZE = ${WSOLA_HOP_SIZE};
911
+ var TOLERANCE = ${WSOLA_TOLERANCE};
912
+
913
+ function createHannWindow(size) {
914
+ var w = new Float32Array(size);
915
+ for (var i = 0; i < size; i++) {
916
+ w[i] = 0.5 * (1 - Math.cos((2 * Math.PI * i) / (size - 1)));
826
917
  }
827
- function updatePriorities(playheadIndex) {
828
- currentChunkIdx = playheadIndex;
829
- queue.clear();
830
- for (const chunk of chunks) {
831
- if (chunk.state === "pending" || chunk.state === "queued" || chunk.state === "failed") {
832
- chunk.priority = calcPriority(chunk.index, playheadIndex);
833
- chunk.state = "queued";
834
- queue.enqueue(chunk);
835
- } else if (chunk.state === "evicted" && isInActiveWindow(chunk.index, playheadIndex)) {
836
- chunk.state = "queued";
837
- chunk.retryCount = 0;
838
- chunk.priority = calcPriority(chunk.index, playheadIndex);
839
- queue.enqueue(chunk);
840
- }
841
- }
842
- for (const chunk of chunks) {
843
- if (chunk.state === "converting") {
844
- const dist = Math.abs(chunk.index - playheadIndex);
845
- if (dist > cancelDistThreshold) {
846
- workerManager.cancelChunk(chunk.index);
847
- }
848
- }
918
+ return w;
919
+ }
920
+
921
+ function findBestOffset(ref, search, overlapSize, maxOffset) {
922
+ var bestOffset = 0;
923
+ var bestCorr = -Infinity;
924
+ var searchLen = search.length;
925
+ var refLen = ref.length;
926
+ var len = Math.min(overlapSize, refLen);
927
+
928
+ for (var offset = 0; offset <= maxOffset; offset++) {
929
+ if (offset + len > searchLen) break;
930
+ var corr = 0;
931
+ var normRef = 0;
932
+ var normSearch = 0;
933
+ for (var i = 0; i < len; i++) {
934
+ var r = ref[i];
935
+ var s = search[offset + i];
936
+ corr += r * s;
937
+ normRef += r * r;
938
+ normSearch += s * s;
849
939
  }
850
- }
851
- function dispatchNext() {
852
- if (disposed) return;
853
- while (workerManager.hasCapacity()) {
854
- let nextChunk = queue.dequeue();
855
- while (nextChunk && nextChunk.state === "ready") {
856
- nextChunk = queue.dequeue();
857
- }
858
- if (!nextChunk) return;
859
- if (nextChunk.state !== "queued" && nextChunk.state !== "pending" && nextChunk.state !== "failed") {
860
- continue;
861
- }
862
- nextChunk.state = "converting";
863
- const data = extractChunkData2(nextChunk.index);
864
- workerManager.postConvert(
865
- nextChunk.index,
866
- data,
867
- currentTempo,
868
- sampleRate
869
- );
940
+ var denom = Math.sqrt(normRef * normSearch);
941
+ var ncc = denom > 1e-10 ? corr / denom : 0;
942
+ if (ncc > bestCorr) {
943
+ bestCorr = ncc;
944
+ bestOffset = offset;
870
945
  }
871
946
  }
872
- function handleResult(chunkIndex, outputData, outputLength) {
873
- const chunk = chunks[chunkIndex];
874
- if (!chunk) return;
875
- chunk.state = "ready";
876
- chunk.outputBuffer = outputData;
877
- chunk.outputLength = outputLength;
878
- onChunkReady?.(chunkIndex);
879
- dispatchNext();
880
- }
881
- function handleError(chunkIndex, error) {
882
- const chunk = chunks[chunkIndex];
883
- if (!chunk) return;
884
- chunk.retryCount++;
885
- if (chunk.retryCount < MAX_CHUNK_RETRIES) {
886
- chunk.state = "queued";
887
- chunk.priority = calcPriority(chunk.index, currentChunkIdx);
888
- queue.enqueue(chunk);
889
- } else {
890
- chunk.state = "failed";
891
- onChunkFailed?.(chunkIndex, error);
892
- }
893
- dispatchNext();
947
+ return bestOffset;
948
+ }
949
+
950
+ function wsolaTimeStretch(channels, tempo, sampleRate) {
951
+ if (channels.length === 0) {
952
+ return { output: [], length: 0 };
894
953
  }
895
- function handleSeek(newChunkIndex) {
896
- for (const chunk of chunks) {
897
- if (chunk.state === "converting") {
898
- const dist = Math.abs(chunk.index - newChunkIndex);
899
- if (dist > cancelDistThreshold) {
900
- workerManager.cancelChunk(chunk.index);
901
- }
902
- }
903
- }
904
- updatePriorities(newChunkIndex);
905
- dispatchNext();
954
+
955
+ var inputLength = channels[0].length;
956
+ if (inputLength === 0) {
957
+ return { output: channels.map(function() { return new Float32Array(0); }), length: 0 };
906
958
  }
907
- function handleTempoChange(newTempo) {
908
- previousTempoCache = {
909
- tempo: currentTempo,
910
- chunks: chunks.map((c) => {
911
- if (isInActiveWindow(c.index, currentChunkIdx) && c.outputBuffer) {
912
- return { outputBuffer: c.outputBuffer, outputLength: c.outputLength };
913
- }
914
- return { outputBuffer: null, outputLength: 0 };
915
- })
959
+
960
+ var TEMPO_IDENTITY_EPSILON = 0.001;
961
+ if (Math.abs(tempo - 1.0) < TEMPO_IDENTITY_EPSILON) {
962
+ return {
963
+ output: channels.map(function(ch) { return new Float32Array(ch); }),
964
+ length: inputLength
916
965
  };
917
- currentTempo = newTempo;
918
- workerManager.cancelCurrent();
919
- for (const chunk of chunks) {
920
- if (chunk.state === "evicted") continue;
921
- if (isInActiveWindow(chunk.index, currentChunkIdx)) {
922
- chunk.outputBuffer = null;
923
- chunk.outputLength = 0;
924
- chunk.state = "pending";
925
- chunk.retryCount = 0;
926
- } else {
927
- chunk.outputBuffer = null;
928
- chunk.outputLength = 0;
929
- chunk.state = "evicted";
930
- }
931
- }
932
- updatePriorities(currentChunkIdx);
933
- dispatchNext();
934
- }
935
- function restorePreviousTempo() {
936
- if (!previousTempoCache) return false;
937
- currentTempo = previousTempoCache.tempo;
938
- for (let i = 0; i < chunks.length; i++) {
939
- const cached = previousTempoCache.chunks[i];
940
- const chunk = chunks[i];
941
- if (chunk && cached?.outputBuffer) {
942
- chunk.outputBuffer = cached.outputBuffer;
943
- chunk.outputLength = cached.outputLength;
944
- chunk.state = "ready";
945
- }
946
- }
947
- previousTempoCache = null;
948
- workerManager.cancelCurrent();
949
- updatePriorities(currentChunkIdx);
950
- dispatchNext();
951
- return true;
952
966
  }
953
- function start(playheadIndex) {
954
- updatePriorities(playheadIndex);
955
- dispatchNext();
967
+
968
+ var synthesisHop = HOP_SIZE;
969
+ var analysisHop = Math.round(HOP_SIZE * tempo);
970
+ var numFrames = Math.floor((inputLength - FRAME_SIZE) / analysisHop) + 1;
971
+
972
+ if (numFrames <= 0) {
973
+ return {
974
+ output: channels.map(function(ch) { return new Float32Array(ch); }),
975
+ length: inputLength
976
+ };
956
977
  }
957
- return {
958
- start,
959
- updatePriorities,
960
- handleSeek,
961
- handleTempoChange,
962
- restorePreviousTempo,
963
- dispatchNext,
964
- getChunks: () => chunks,
965
- dispose() {
966
- disposed = true;
967
- queue.clear();
968
- },
969
- // Expose internal handlers for the engine to wire up
970
- _handleResult: handleResult,
971
- _handleError: handleError
972
- };
973
- }
974
978
 
975
- // src/stretcher/chunk-player.ts
976
- var CURVE_LENGTH = 256;
977
- function createEqualPowerCurve(fadeIn) {
978
- const curve = new Float32Array(CURVE_LENGTH);
979
- for (let i = 0; i < CURVE_LENGTH; i++) {
980
- const t = i / (CURVE_LENGTH - 1);
981
- curve[i] = fadeIn ? Math.sin(t * Math.PI / 2) : Math.cos(t * Math.PI / 2);
982
- }
983
- return curve;
984
- }
985
- var fadeInCurve = createEqualPowerCurve(true);
986
- var fadeOutCurve = createEqualPowerCurve(false);
987
- function createChunkPlayer(ctx, options) {
988
- const destination = options.destination ?? ctx.destination;
989
- const through = options.through ?? [];
990
- const crossfadeSec = options.crossfadeSec ?? CROSSFADE_SEC;
991
- let currentSource = null;
992
- let nextSource = null;
993
- let currentGain = null;
994
- let nextGain = null;
995
- let playStartCtxTime = 0;
996
- let playStartOffset = 0;
997
- let currentChunkDuration = 0;
998
- let paused = false;
999
- let pausedPosition = 0;
1000
- let stopped = true;
1001
- let lookaheadTimer = null;
1002
- let transitionTimerId = null;
1003
- let onChunkEnded = null;
1004
- let onNeedNext = null;
1005
- let onTransition = null;
1006
- let disposed = false;
1007
- function connectToDestination(node) {
1008
- if (through.length > 0) {
1009
- node.connect(through[0]);
1010
- for (let i = 0; i < through.length - 1; i++) {
1011
- through[i].connect(through[i + 1]);
979
+ var estimatedOutputLength = (numFrames - 1) * synthesisHop + FRAME_SIZE;
980
+ var outputChannels = channels.map(function() {
981
+ return new Float32Array(estimatedOutputLength);
982
+ });
983
+ var windowFunc = createHannWindow(FRAME_SIZE);
984
+ var normBuffer = new Float32Array(estimatedOutputLength);
985
+
986
+ var prevOutputFrame = channels.map(function() {
987
+ return new Float32Array(FRAME_SIZE);
988
+ });
989
+
990
+ var inputPos = 0;
991
+ var outputPos = 0;
992
+ var actualOutputLength = 0;
993
+
994
+ for (var frame = 0; frame < numFrames; frame++) {
995
+ if (cancelled) return null;
996
+ if (inputPos + FRAME_SIZE > inputLength) break;
997
+
998
+ var actualInputPos = inputPos;
999
+
1000
+ if (frame > 0 && TOLERANCE > 0) {
1001
+ var searchStart = Math.max(0, inputPos - TOLERANCE);
1002
+ var searchEnd = Math.min(inputLength - FRAME_SIZE, inputPos + TOLERANCE);
1003
+ var searchRange = searchEnd - searchStart;
1004
+
1005
+ if (searchRange > 0) {
1006
+ var refChannel = prevOutputFrame[0];
1007
+ var inputChannel = channels[0];
1008
+
1009
+ var overlapStart = FRAME_SIZE - synthesisHop;
1010
+ var overlapSize = Math.min(synthesisHop, FRAME_SIZE - overlapStart);
1011
+
1012
+ var refSlice = refChannel.subarray(overlapStart, overlapStart + overlapSize);
1013
+ var searchSlice = inputChannel.subarray(searchStart, searchEnd + overlapSize);
1014
+
1015
+ var bestOffset = findBestOffset(
1016
+ refSlice, searchSlice, overlapSize,
1017
+ Math.min(searchRange, searchSlice.length - overlapSize)
1018
+ );
1019
+
1020
+ actualInputPos = searchStart + bestOffset;
1012
1021
  }
1013
- through[through.length - 1].connect(destination);
1014
- } else {
1015
- node.connect(destination);
1016
1022
  }
1017
- }
1018
- function createSourceFromBuffer(buffer, gain) {
1019
- const src = ctx.createBufferSource();
1020
- src.buffer = buffer;
1021
- src.connect(gain);
1022
- connectToDestination(gain);
1023
- return src;
1024
- }
1025
- function stopCurrentSource() {
1026
- if (currentSource) {
1027
- currentSource.onended = null;
1028
- try {
1029
- currentSource.stop();
1030
- } catch {
1023
+
1024
+ for (var ch = 0; ch < channels.length; ch++) {
1025
+ var input = channels[ch];
1026
+ var output = outputChannels[ch];
1027
+ var prevFrame = prevOutputFrame[ch];
1028
+ for (var i = 0; i < FRAME_SIZE; i++) {
1029
+ var inIdx = actualInputPos + i;
1030
+ if (inIdx >= inputLength) break;
1031
+ var outIdx = outputPos + i;
1032
+ if (outIdx >= estimatedOutputLength) break;
1033
+ var sample = input[inIdx];
1034
+ output[outIdx] += sample * windowFunc[i];
1035
+ prevFrame[i] = sample * windowFunc[i];
1031
1036
  }
1032
- currentSource.disconnect();
1033
- currentSource = null;
1034
1037
  }
1035
- if (currentGain) {
1036
- currentGain.disconnect();
1037
- currentGain = null;
1038
+
1039
+ for (var i = 0; i < FRAME_SIZE; i++) {
1040
+ var outIdx = outputPos + i;
1041
+ if (outIdx >= estimatedOutputLength) break;
1042
+ normBuffer[outIdx] += windowFunc[i];
1038
1043
  }
1044
+
1045
+ inputPos += analysisHop;
1046
+ outputPos += synthesisHop;
1047
+ actualOutputLength = Math.min(outputPos + FRAME_SIZE, estimatedOutputLength);
1039
1048
  }
1040
- function stopNextSource() {
1041
- if (nextSource) {
1042
- nextSource.onended = null;
1043
- try {
1044
- nextSource.stop();
1045
- } catch {
1049
+
1050
+ for (var ch = 0; ch < outputChannels.length; ch++) {
1051
+ var output = outputChannels[ch];
1052
+ for (var i = 0; i < actualOutputLength; i++) {
1053
+ var norm = normBuffer[i];
1054
+ if (norm > 1e-8) {
1055
+ output[i] /= norm;
1046
1056
  }
1047
- nextSource.disconnect();
1048
- nextSource = null;
1049
- }
1050
- if (nextGain) {
1051
- nextGain.disconnect();
1052
- nextGain = null;
1053
1057
  }
1054
1058
  }
1055
- function startLookahead() {
1056
- if (lookaheadTimer !== null) return;
1057
- lookaheadTimer = setInterval(() => {
1058
- if (paused || stopped || disposed) return;
1059
- const pos = getElapsedInChunk();
1060
- const remaining = currentChunkDuration - pos;
1061
- if (remaining <= LOOKAHEAD_THRESHOLD_SEC && !nextSource) {
1062
- onNeedNext?.();
1063
- }
1064
- }, LOOKAHEAD_INTERVAL_MS);
1059
+
1060
+ var trimmedOutput = outputChannels.map(function(ch) {
1061
+ return ch.slice(0, actualOutputLength);
1062
+ });
1063
+
1064
+ return { output: trimmedOutput, length: actualOutputLength };
1065
+ }
1066
+
1067
+ var cancelled = false;
1068
+
1069
+ self.onmessage = function(e) {
1070
+ var msg = e.data;
1071
+ if (msg.type === "cancel") {
1072
+ cancelled = true;
1073
+ return;
1065
1074
  }
1066
- function stopLookahead() {
1067
- if (lookaheadTimer !== null) {
1068
- clearInterval(lookaheadTimer);
1069
- lookaheadTimer = null;
1075
+ if (msg.type === "convert") {
1076
+ cancelled = false;
1077
+ try {
1078
+ var result = wsolaTimeStretch(msg.inputData, msg.tempo, msg.sampleRate);
1079
+ if (cancelled || result === null) {
1080
+ self.postMessage({ type: "cancelled", chunkIndex: msg.chunkIndex });
1081
+ } else {
1082
+ self.postMessage(
1083
+ { type: "result", chunkIndex: msg.chunkIndex, outputData: result.output, outputLength: result.length },
1084
+ result.output.map(function(ch) { return ch.buffer; })
1085
+ );
1086
+ }
1087
+ } catch (err) {
1088
+ self.postMessage({ type: "error", chunkIndex: msg.chunkIndex, error: String(err) });
1070
1089
  }
1071
1090
  }
1072
- function cancelTransition() {
1073
- if (transitionTimerId !== null) {
1074
- clearTimeout(transitionTimerId);
1075
- transitionTimerId = null;
1091
+ };
1092
+ `;
1093
+ }
1094
+ function createWorkerURL() {
1095
+ const code = getWorkerCode();
1096
+ const blob = new Blob([code], { type: "application/javascript" });
1097
+ return URL.createObjectURL(blob);
1098
+ }
1099
+ function revokeWorkerURL(url) {
1100
+ URL.revokeObjectURL(url);
1101
+ }
1102
+
1103
+ // src/stretcher/worker-manager.ts
1104
+ function createWorkerManager(onResult, onError, maxCrashes = MAX_WORKER_CRASHES, poolSize = WORKER_POOL_SIZE, onAllDead) {
1105
+ let workerURL = null;
1106
+ let terminated = false;
1107
+ let allDeadFired = false;
1108
+ const postTimes = /* @__PURE__ */ new Map();
1109
+ const slots = [];
1110
+ function ensureWorkerURL() {
1111
+ if (!workerURL) {
1112
+ workerURL = createWorkerURL();
1076
1113
  }
1114
+ return workerURL;
1077
1115
  }
1078
- function getElapsedInChunk() {
1079
- if (paused) return pausedPosition;
1080
- if (stopped) return 0;
1081
- return ctx.currentTime - playStartCtxTime + playStartOffset;
1116
+ function isAllDead() {
1117
+ return slots.every((s) => s.worker === null);
1082
1118
  }
1083
- function playChunk(buffer, _startTime, offsetInChunk = 0) {
1084
- cancelTransition();
1085
- stopCurrentSource();
1086
- stopNextSource();
1087
- currentGain = ctx.createGain();
1088
- currentSource = createSourceFromBuffer(buffer, currentGain);
1089
- currentChunkDuration = buffer.duration;
1090
- playStartOffset = offsetInChunk;
1091
- playStartCtxTime = ctx.currentTime;
1092
- paused = false;
1093
- stopped = false;
1094
- currentSource.onended = () => {
1095
- if (!disposed && !paused && !stopped) {
1096
- onChunkEnded?.();
1119
+ function spawnWorkerForSlot(slot) {
1120
+ if (terminated) return;
1121
+ const url = ensureWorkerURL();
1122
+ let worker;
1123
+ try {
1124
+ worker = new Worker(url);
1125
+ } catch {
1126
+ slot.worker = null;
1127
+ return;
1128
+ }
1129
+ worker.onmessage = (e) => {
1130
+ const response = e.data;
1131
+ if (response.type === "result" || response.type === "cancelled") {
1132
+ slot.busy = false;
1133
+ slot.currentChunkIndex = null;
1134
+ postTimes.delete(response.chunkIndex);
1135
+ }
1136
+ if (response.type === "error") {
1137
+ slot.busy = false;
1138
+ slot.currentChunkIndex = null;
1139
+ postTimes.delete(response.chunkIndex);
1140
+ onError(response);
1141
+ return;
1142
+ }
1143
+ onResult(response);
1144
+ };
1145
+ worker.onerror = (e) => {
1146
+ e.preventDefault();
1147
+ slot.busy = false;
1148
+ const failedChunkIndex = slot.currentChunkIndex;
1149
+ slot.currentChunkIndex = null;
1150
+ slot.crashCount++;
1151
+ if (failedChunkIndex !== null) {
1152
+ postTimes.delete(failedChunkIndex);
1153
+ onError({
1154
+ type: "error",
1155
+ chunkIndex: failedChunkIndex,
1156
+ error: `Worker crashed: ${e.message}`
1157
+ });
1158
+ }
1159
+ if (slot.worker) {
1160
+ slot.worker.onmessage = null;
1161
+ slot.worker.onerror = null;
1162
+ slot.worker.terminate();
1163
+ slot.worker = null;
1164
+ }
1165
+ if (slot.crashCount < maxCrashes) {
1166
+ spawnWorkerForSlot(slot);
1167
+ } else {
1168
+ onError({
1169
+ type: "error",
1170
+ chunkIndex: failedChunkIndex ?? -1,
1171
+ error: `Worker crashed ${slot.crashCount} times, giving up`
1172
+ });
1173
+ if (!allDeadFired && isAllDead()) {
1174
+ allDeadFired = true;
1175
+ onAllDead?.();
1176
+ }
1097
1177
  }
1098
1178
  };
1099
- currentSource.start(0, offsetInChunk);
1100
- if (crossfadeSec > 0) {
1101
- currentGain.gain.setValueCurveAtTime(fadeInCurve, ctx.currentTime, crossfadeSec);
1102
- }
1103
- startLookahead();
1179
+ slot.worker = worker;
1104
1180
  }
1105
- function scheduleNext(buffer, startTime) {
1106
- if (disposed) return;
1107
- stopNextSource();
1108
- nextGain = ctx.createGain();
1109
- nextSource = createSourceFromBuffer(buffer, nextGain);
1110
- nextSource.onended = () => {
1111
- if (!disposed && !paused && !stopped) {
1112
- onChunkEnded?.();
1113
- }
1181
+ for (let i = 0; i < poolSize; i++) {
1182
+ const slot = {
1183
+ worker: null,
1184
+ busy: false,
1185
+ currentChunkIndex: null,
1186
+ crashCount: 0
1114
1187
  };
1115
- nextSource.start(startTime - crossfadeSec);
1116
- if (crossfadeSec > 0 && currentGain) {
1117
- currentGain.gain.setValueCurveAtTime(fadeOutCurve, startTime - crossfadeSec, crossfadeSec);
1118
- nextGain.gain.setValueCurveAtTime(fadeInCurve, startTime - crossfadeSec, crossfadeSec);
1119
- }
1120
- const transitionDelay = Math.max(
1121
- 0,
1122
- (startTime - ctx.currentTime) * 1e3 + 50
1123
- );
1124
- cancelTransition();
1125
- transitionTimerId = setTimeout(() => {
1126
- transitionTimerId = null;
1127
- if (disposed) return;
1128
- stopCurrentSource();
1129
- currentSource = nextSource;
1130
- currentGain = nextGain;
1131
- nextSource = null;
1132
- nextGain = null;
1133
- currentChunkDuration = buffer.duration;
1134
- playStartOffset = 0;
1135
- playStartCtxTime = startTime - crossfadeSec;
1136
- onTransition?.();
1137
- }, transitionDelay);
1138
- }
1139
- function handleSeek(buffer, offsetInChunk) {
1140
- playChunk(buffer, 0, offsetInChunk);
1141
- }
1142
- function pause() {
1143
- if (paused || stopped || disposed) return;
1144
- pausedPosition = getElapsedInChunk();
1145
- paused = true;
1146
- cancelTransition();
1147
- stopCurrentSource();
1148
- stopNextSource();
1149
- stopLookahead();
1150
- }
1151
- function resume() {
1152
- if (!paused || disposed) return;
1188
+ slots.push(slot);
1189
+ spawnWorkerForSlot(slot);
1153
1190
  }
1154
- function stop() {
1155
- if (stopped || disposed) return;
1156
- stopped = true;
1157
- paused = false;
1158
- pausedPosition = 0;
1159
- cancelTransition();
1160
- stopCurrentSource();
1161
- stopNextSource();
1162
- stopLookahead();
1191
+ if (!allDeadFired && isAllDead()) {
1192
+ allDeadFired = true;
1193
+ onAllDead?.();
1163
1194
  }
1164
- function getCurrentPosition() {
1165
- return getElapsedInChunk();
1195
+ function findFreeSlot() {
1196
+ for (const slot of slots) {
1197
+ if (!slot.busy && slot.worker) {
1198
+ return slot;
1199
+ }
1200
+ }
1201
+ return null;
1166
1202
  }
1167
- function hasNextScheduled() {
1168
- return nextSource !== null;
1203
+ function findSlotByChunk(chunkIndex) {
1204
+ for (const slot of slots) {
1205
+ if (slot.busy && slot.currentChunkIndex === chunkIndex) {
1206
+ return slot;
1207
+ }
1208
+ }
1209
+ return null;
1169
1210
  }
1170
1211
  return {
1171
- playChunk,
1172
- scheduleNext,
1173
- hasNextScheduled,
1174
- handleSeek,
1175
- pause,
1176
- resume,
1177
- stop,
1178
- getCurrentPosition,
1179
- setOnChunkEnded(callback) {
1180
- onChunkEnded = callback;
1212
+ postConvert(chunkIndex, inputData, tempo, sampleRate) {
1213
+ if (terminated) return;
1214
+ const slot = findFreeSlot();
1215
+ if (!slot || !slot.worker) return;
1216
+ slot.busy = true;
1217
+ slot.currentChunkIndex = chunkIndex;
1218
+ postTimes.set(chunkIndex, performance.now());
1219
+ const transferables = inputData.map((ch) => ch.buffer);
1220
+ slot.worker.postMessage(
1221
+ { type: "convert", chunkIndex, inputData, tempo, sampleRate },
1222
+ transferables
1223
+ );
1181
1224
  },
1182
- setOnNeedNext(callback) {
1183
- onNeedNext = callback;
1225
+ cancelCurrent() {
1226
+ if (terminated) return;
1227
+ for (const slot of slots) {
1228
+ if (slot.busy && slot.worker && slot.currentChunkIndex !== null) {
1229
+ slot.worker.postMessage({ type: "cancel", chunkIndex: slot.currentChunkIndex });
1230
+ }
1231
+ }
1184
1232
  },
1185
- setOnTransition(callback) {
1186
- onTransition = callback;
1233
+ cancelChunk(chunkIndex) {
1234
+ if (terminated) return;
1235
+ const slot = findSlotByChunk(chunkIndex);
1236
+ if (slot?.worker) {
1237
+ slot.worker.postMessage({ type: "cancel", chunkIndex });
1238
+ }
1187
1239
  },
1188
- dispose() {
1189
- if (disposed) return;
1190
- disposed = true;
1191
- cancelTransition();
1192
- stopCurrentSource();
1193
- stopNextSource();
1194
- stopLookahead();
1195
- }
1196
- };
1197
- }
1198
-
1199
- // src/stretcher/buffer-monitor.ts
1200
- function createBufferMonitor(options) {
1201
- const healthySec = BUFFER_HEALTHY_SEC;
1202
- const lowSec = BUFFER_LOW_SEC;
1203
- const criticalSec = BUFFER_CRITICAL_SEC;
1204
- const resumeSec = BUFFER_RESUME_SEC;
1205
- const chunkDurSec = CHUNK_DURATION_SEC;
1206
- function getAheadSeconds(currentChunkIndex, chunks) {
1207
- let aheadSec = 0;
1208
- for (let i = currentChunkIndex; i < chunks.length; i++) {
1209
- const chunk = chunks[i];
1210
- if (!chunk || chunk.state !== "ready") break;
1211
- aheadSec += chunkDurSec;
1240
+ isBusy() {
1241
+ return slots.every((s) => s.busy || !s.worker);
1242
+ },
1243
+ hasCapacity() {
1244
+ return findFreeSlot() !== null;
1245
+ },
1246
+ getCurrentChunkIndex() {
1247
+ for (const slot of slots) {
1248
+ if (slot.busy && slot.currentChunkIndex !== null) {
1249
+ return slot.currentChunkIndex;
1250
+ }
1251
+ }
1252
+ return null;
1253
+ },
1254
+ getLastPostTime() {
1255
+ let latest = null;
1256
+ for (const t of postTimes.values()) {
1257
+ if (latest === null || t > latest) {
1258
+ latest = t;
1259
+ }
1260
+ }
1261
+ return latest;
1262
+ },
1263
+ getPostTimeForChunk(chunkIndex) {
1264
+ return postTimes.get(chunkIndex) ?? null;
1265
+ },
1266
+ terminate() {
1267
+ if (terminated) return;
1268
+ terminated = true;
1269
+ for (const slot of slots) {
1270
+ if (slot.worker) {
1271
+ slot.worker.onmessage = null;
1272
+ slot.worker.onerror = null;
1273
+ slot.worker.terminate();
1274
+ slot.worker = null;
1275
+ }
1276
+ }
1277
+ if (workerURL) {
1278
+ revokeWorkerURL(workerURL);
1279
+ workerURL = null;
1280
+ }
1281
+ postTimes.clear();
1212
1282
  }
1213
- return aheadSec;
1214
- }
1215
- function getHealth(currentChunkIndex, chunks) {
1216
- const ahead = getAheadSeconds(currentChunkIndex, chunks);
1217
- if (ahead >= healthySec) return "healthy";
1218
- if (ahead >= lowSec) return "low";
1219
- if (ahead >= criticalSec) return "critical";
1220
- return "empty";
1221
- }
1222
- function shouldEnterBuffering(currentChunkIndex, chunks) {
1223
- const ahead = getAheadSeconds(currentChunkIndex, chunks);
1224
- if (ahead >= criticalSec) return false;
1225
- const nextChunk = chunks[currentChunkIndex + 1];
1226
- if (nextChunk && nextChunk.state === "ready") return false;
1227
- const currentChunk = chunks[currentChunkIndex];
1228
- if (!currentChunk || currentChunk.state !== "ready") return true;
1229
- return ahead < criticalSec;
1230
- }
1231
- function shouldExitBuffering(currentChunkIndex, chunks) {
1232
- const currentChunk = chunks[currentChunkIndex];
1233
- if (!currentChunk || currentChunk.state !== "ready") return false;
1234
- const ahead = getAheadSeconds(currentChunkIndex, chunks);
1235
- if (ahead >= resumeSec) return true;
1236
- const nextChunk = chunks[currentChunkIndex + 1];
1237
- if (nextChunk && nextChunk.state === "ready") return true;
1238
- const allReady = chunks.every(
1239
- (c) => c.state === "ready" || c.state === "skipped"
1240
- );
1241
- if (allReady) return true;
1242
- return false;
1243
- }
1244
- return {
1245
- getHealth,
1246
- getAheadSeconds,
1247
- shouldEnterBuffering,
1248
- shouldExitBuffering
1249
1283
  };
1250
1284
  }
1251
1285
 
@@ -1256,7 +1290,7 @@ function trimOverlap(outputData, outputLength, chunk, sampleRate) {
1256
1290
  return { data: outputData, length: outputLength };
1257
1291
  }
1258
1292
  const ratio = outputLength / inputLength;
1259
- const crossfadeKeep = Math.round(CROSSFADE_SEC * sampleRate);
1293
+ const crossfadeKeep = Math.round(CROSSFADE_SEC * sampleRate * Math.min(1, ratio));
1260
1294
  const overlapBeforeOutput = Math.round(chunk.overlapBefore * ratio);
1261
1295
  const overlapAfterOutput = Math.round(chunk.overlapAfter * ratio);
1262
1296
  const keepBefore = chunk.overlapBefore > 0 ? Math.min(crossfadeKeep, overlapBeforeOutput) : 0;
@@ -1282,7 +1316,7 @@ function createStretcherEngine(ctx, buffer, options) {
1282
1316
  through = [],
1283
1317
  destination = ctx.destination
1284
1318
  } = options;
1285
- const emitter = chunk5J7S6QV3_cjs.createEmitter();
1319
+ const emitter = chunkHIF3UAF3_cjs.createEmitter();
1286
1320
  const sampleRate = buffer.sampleRate;
1287
1321
  const totalDuration = buffer.duration;
1288
1322
  let phase = "waiting";
@@ -1292,14 +1326,15 @@ function createStretcherEngine(ctx, buffer, options) {
1292
1326
  let bufferingStartTime = 0;
1293
1327
  let currentChunkIndex = 0;
1294
1328
  let bufferingResumePosition = null;
1329
+ let expectedTransitionFrom = null;
1330
+ let nextChunkScheduledIndex = null;
1331
+ let pendingTempoChange = false;
1295
1332
  const keepAhead = Math.max(KEEP_AHEAD_CHUNKS, Math.ceil(KEEP_AHEAD_SECONDS / CHUNK_DURATION_SEC));
1296
- const keepBehind = Math.max(KEEP_BEHIND_CHUNKS, Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC));
1297
- const chunks = splitIntoChunks(
1298
- buffer.length,
1299
- sampleRate,
1300
- CHUNK_DURATION_SEC,
1301
- OVERLAP_SEC
1333
+ const keepBehind = Math.max(
1334
+ KEEP_BEHIND_CHUNKS,
1335
+ Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC)
1302
1336
  );
1337
+ const chunks = splitIntoChunks(buffer.length, sampleRate, CHUNK_DURATION_SEC, OVERLAP_SEC);
1303
1338
  const monitor = createBufferMonitor();
1304
1339
  const poolSize = options.workerPoolSize ?? WORKER_POOL_SIZE;
1305
1340
  function handleWorkerResult(response) {
@@ -1315,11 +1350,7 @@ function createStretcherEngine(ctx, buffer, options) {
1315
1350
  chunk,
1316
1351
  sampleRate
1317
1352
  );
1318
- schedulerInternal._handleResult(
1319
- response.chunkIndex,
1320
- trimmed.data,
1321
- trimmed.length
1322
- );
1353
+ schedulerInternal._handleResult(response.chunkIndex, trimmed.data, trimmed.length);
1323
1354
  }
1324
1355
  } else if (response.type === "cancelled") {
1325
1356
  const chunk = chunks[response.chunkIndex];
@@ -1332,10 +1363,7 @@ function createStretcherEngine(ctx, buffer, options) {
1332
1363
  function handleWorkerError(response) {
1333
1364
  if (disposed) return;
1334
1365
  if (response.type === "error") {
1335
- schedulerInternal._handleError(
1336
- response.chunkIndex,
1337
- response.error ?? "Unknown error"
1338
- );
1366
+ schedulerInternal._handleError(response.chunkIndex, response.error ?? "Unknown error");
1339
1367
  }
1340
1368
  }
1341
1369
  function switchToMainThread() {
@@ -1371,26 +1399,34 @@ function createStretcherEngine(ctx, buffer, options) {
1371
1399
  if (disposed || phase === "paused" || phase === "ended") return;
1372
1400
  advanceToNextChunk();
1373
1401
  });
1402
+ function tryScheduleNext(nextIdx) {
1403
+ if (nextChunkScheduledIndex === nextIdx) return;
1404
+ if (nextIdx >= chunks.length) return;
1405
+ const nextChunk = chunks[nextIdx];
1406
+ if (!nextChunk || nextChunk.state !== "ready" || !nextChunk.outputBuffer) return;
1407
+ if (chunkPlayer.hasNextScheduled()) return;
1408
+ const curChunk = chunks[currentChunkIndex];
1409
+ const curOutputDuration = curChunk ? curChunk.outputLength / sampleRate : 0;
1410
+ const elapsed = chunkPlayer.getCurrentPosition();
1411
+ const remaining = curOutputDuration - elapsed;
1412
+ if (remaining <= 0) return;
1413
+ const audioBuffer = createAudioBufferFromChunk(nextChunk);
1414
+ if (!audioBuffer) return;
1415
+ const startTime = ctx.currentTime + remaining;
1416
+ expectedTransitionFrom = currentChunkIndex;
1417
+ chunkPlayer.scheduleNext(audioBuffer, startTime);
1418
+ nextChunkScheduledIndex = nextIdx;
1419
+ }
1374
1420
  chunkPlayer.setOnNeedNext(() => {
1375
1421
  if (disposed) return;
1376
- const nextIdx = currentChunkIndex + 1;
1377
- if (nextIdx < chunks.length) {
1378
- const nextChunk = chunks[nextIdx];
1379
- if (nextChunk.state === "ready" && nextChunk.outputBuffer) {
1380
- const audioBuffer = createAudioBufferFromChunk(nextChunk);
1381
- if (audioBuffer) {
1382
- const curChunk = chunks[currentChunkIndex];
1383
- const curOutputDuration = curChunk ? curChunk.outputLength / sampleRate : 0;
1384
- const elapsed = chunkPlayer.getCurrentPosition();
1385
- const remaining = curOutputDuration - elapsed;
1386
- const startTime = ctx.currentTime + Math.max(0, remaining);
1387
- chunkPlayer.scheduleNext(audioBuffer, startTime);
1388
- }
1389
- }
1390
- }
1422
+ tryScheduleNext(currentChunkIndex + 1);
1391
1423
  });
1392
1424
  chunkPlayer.setOnTransition(() => {
1393
1425
  if (disposed) return;
1426
+ if (expectedTransitionFrom !== null && currentChunkIndex !== expectedTransitionFrom) {
1427
+ return;
1428
+ }
1429
+ nextChunkScheduledIndex = null;
1394
1430
  const nextIdx = currentChunkIndex + 1;
1395
1431
  if (nextIdx < chunks.length) {
1396
1432
  currentChunkIndex = nextIdx;
@@ -1409,21 +1445,12 @@ function createStretcherEngine(ctx, buffer, options) {
1409
1445
  }
1410
1446
  }
1411
1447
  if (phase === "playing" && chunkIndex === currentChunkIndex + 1) {
1412
- if (!chunkPlayer.hasNextScheduled()) {
1413
- const curChunk = chunks[currentChunkIndex];
1414
- const curOutputDuration = curChunk ? curChunk.outputLength / sampleRate : 0;
1415
- const elapsed = chunkPlayer.getCurrentPosition();
1416
- const remaining = curOutputDuration - elapsed;
1417
- if (remaining <= LOOKAHEAD_THRESHOLD_SEC) {
1418
- const nextChunk = chunks[chunkIndex];
1419
- if (nextChunk && nextChunk.outputBuffer) {
1420
- const audioBuffer = createAudioBufferFromChunk(nextChunk);
1421
- if (audioBuffer) {
1422
- const startTime = ctx.currentTime + Math.max(0, remaining);
1423
- chunkPlayer.scheduleNext(audioBuffer, startTime);
1424
- }
1425
- }
1426
- }
1448
+ const curChunk = chunks[currentChunkIndex];
1449
+ const curOutputDuration = curChunk ? curChunk.outputLength / sampleRate : 0;
1450
+ const elapsed = chunkPlayer.getCurrentPosition();
1451
+ const remaining = curOutputDuration - elapsed;
1452
+ if (remaining <= PROACTIVE_SCHEDULE_THRESHOLD_SEC) {
1453
+ tryScheduleNext(chunkIndex);
1427
1454
  }
1428
1455
  }
1429
1456
  const allDone = chunks.every(
@@ -1443,23 +1470,19 @@ function createStretcherEngine(ctx, buffer, options) {
1443
1470
  function createAudioBufferFromChunk(chunk) {
1444
1471
  if (!chunk.outputBuffer || chunk.outputLength === 0) return null;
1445
1472
  const numChannels = chunk.outputBuffer.length;
1446
- const audioBuf = ctx.createBuffer(
1447
- numChannels,
1448
- chunk.outputLength,
1449
- sampleRate
1450
- );
1473
+ const audioBuf = ctx.createBuffer(numChannels, chunk.outputLength, sampleRate);
1451
1474
  for (let ch = 0; ch < numChannels; ch++) {
1452
1475
  const channelData = chunk.outputBuffer[ch];
1453
1476
  audioBuf.getChannelData(ch).set(channelData.subarray(0, chunk.outputLength));
1454
1477
  }
1455
1478
  return audioBuf;
1456
1479
  }
1457
- function playCurrentChunk(offsetInBuffer = 0) {
1480
+ function playCurrentChunk(offsetInBuffer = 0, skipFadeIn = false) {
1458
1481
  const chunk = chunks[currentChunkIndex];
1459
1482
  if (!chunk || chunk.state !== "ready" || !chunk.outputBuffer) return;
1460
1483
  const audioBuf = createAudioBufferFromChunk(chunk);
1461
1484
  if (!audioBuf) return;
1462
- chunkPlayer.playChunk(audioBuf, ctx.currentTime, offsetInBuffer);
1485
+ chunkPlayer.playChunk(audioBuf, ctx.currentTime, offsetInBuffer, skipFadeIn);
1463
1486
  }
1464
1487
  function advanceToNextChunk() {
1465
1488
  const nextIdx = currentChunkIndex + 1;
@@ -1488,6 +1511,7 @@ function createStretcherEngine(ctx, buffer, options) {
1488
1511
  return;
1489
1512
  }
1490
1513
  currentChunkIndex = nextIdx;
1514
+ nextChunkScheduledIndex = null;
1491
1515
  scheduler.updatePriorities(currentChunkIndex);
1492
1516
  const chunk = chunks[currentChunkIndex];
1493
1517
  if (chunk && chunk.state === "ready") {
@@ -1505,6 +1529,7 @@ function createStretcherEngine(ctx, buffer, options) {
1505
1529
  if (phase === "buffering" && reason !== "tempo-change" && reason !== "seek") return;
1506
1530
  phase = "buffering";
1507
1531
  bufferingStartTime = performance.now();
1532
+ nextChunkScheduledIndex = null;
1508
1533
  chunkPlayer.pause();
1509
1534
  emitter.emit("buffering", { reason });
1510
1535
  }
@@ -1566,26 +1591,22 @@ function createStretcherEngine(ctx, buffer, options) {
1566
1591
  });
1567
1592
  }
1568
1593
  function getPositionInOriginalBuffer() {
1569
- if (phase === "ended") return totalDuration;
1570
- if (phase === "waiting") return offset;
1571
- if (phase === "buffering" && bufferingResumePosition !== null) {
1572
- return bufferingResumePosition;
1573
- }
1574
- const chunk = chunks[currentChunkIndex];
1575
- if (!chunk) return 0;
1576
- const nominalStartSample = chunk.inputStartSample + chunk.overlapBefore;
1577
- const nominalStartSec = nominalStartSample / sampleRate;
1578
- const posInChunk = chunkPlayer.getCurrentPosition();
1579
- const crossfadeOffset = chunk.overlapBefore > 0 ? CROSSFADE_SEC : 0;
1580
- const adjustedPosInChunk = Math.max(0, posInChunk - crossfadeOffset);
1581
- const posInOriginal = adjustedPosInChunk * currentTempo;
1582
- return Math.min(nominalStartSec + posInOriginal, totalDuration);
1594
+ const chunk = chunks[currentChunkIndex] ?? null;
1595
+ return calcPositionInOriginalBuffer({
1596
+ phase,
1597
+ totalDuration,
1598
+ offset,
1599
+ bufferingResumePosition,
1600
+ currentTempo,
1601
+ sampleRate,
1602
+ crossfadeSec: CROSSFADE_SEC,
1603
+ chunk: chunk ? { inputStartSample: chunk.inputStartSample, overlapBefore: chunk.overlapBefore } : null,
1604
+ posInChunk: chunkPlayer.getCurrentPosition()
1605
+ });
1583
1606
  }
1584
1607
  function getStatus() {
1585
1608
  const readyCount = chunks.filter((c) => c.state === "ready").length;
1586
- const convertingCount = chunks.filter(
1587
- (c) => c.state === "converting"
1588
- ).length;
1609
+ const convertingCount = chunks.filter((c) => c.state === "converting").length;
1589
1610
  const total = chunks.length;
1590
1611
  return {
1591
1612
  phase,
@@ -1609,9 +1630,7 @@ function createStretcherEngine(ctx, buffer, options) {
1609
1630
  function getSnapshot() {
1610
1631
  const readyCount = chunks.filter((c) => c.state === "ready").length;
1611
1632
  const total = chunks.length;
1612
- const convertingCount = chunks.filter(
1613
- (c) => c.state === "converting"
1614
- ).length;
1633
+ const convertingCount = chunks.filter((c) => c.state === "converting").length;
1615
1634
  const windowStart = Math.max(0, currentChunkIndex - keepBehind);
1616
1635
  const windowEnd = Math.min(total - 1, currentChunkIndex + keepAhead);
1617
1636
  const windowSize = windowEnd - windowStart + 1;
@@ -1646,12 +1665,37 @@ function createStretcherEngine(ctx, buffer, options) {
1646
1665
  }
1647
1666
  function resume() {
1648
1667
  if (disposed || phase !== "paused") return;
1668
+ if (pendingTempoChange) {
1669
+ pendingTempoChange = false;
1670
+ currentChunkIndex = getChunkIndexForTime(chunks, bufferingResumePosition, sampleRate);
1671
+ enterBuffering("tempo-change");
1672
+ scheduler.updatePriorities(currentChunkIndex);
1673
+ scheduler.handleTempoChange(currentTempo);
1674
+ return;
1675
+ }
1676
+ if (bufferingResumePosition !== null) {
1677
+ const resumePos = bufferingResumePosition;
1678
+ bufferingResumePosition = null;
1679
+ const chunk2 = chunks[currentChunkIndex];
1680
+ if (chunk2 && chunk2.state === "ready" && chunk2.outputBuffer) {
1681
+ const nominalStartSample = chunk2.inputStartSample + chunk2.overlapBefore;
1682
+ const nominalStartSec = nominalStartSample / sampleRate;
1683
+ const offsetInOriginal = resumePos - nominalStartSec;
1684
+ const offsetInOutput = Math.max(0, offsetInOriginal / currentTempo);
1685
+ phase = "playing";
1686
+ playCurrentChunk(getCrossfadeStart(chunk2) + offsetInOutput, true);
1687
+ } else {
1688
+ bufferingResumePosition = resumePos;
1689
+ enterBuffering("seek");
1690
+ }
1691
+ return;
1692
+ }
1649
1693
  const chunk = chunks[currentChunkIndex];
1650
1694
  if (chunk && chunk.state === "ready") {
1651
1695
  const resumePosition = chunkPlayer.getCurrentPosition();
1652
1696
  phase = "playing";
1653
1697
  chunkPlayer.resume();
1654
- playCurrentChunk(resumePosition);
1698
+ playCurrentChunk(resumePosition, true);
1655
1699
  } else {
1656
1700
  enterBuffering("underrun");
1657
1701
  }
@@ -1661,6 +1705,7 @@ function createStretcherEngine(ctx, buffer, options) {
1661
1705
  const clamped = Math.max(0, Math.min(position, totalDuration));
1662
1706
  const newChunkIdx = getChunkIndexForTime(chunks, clamped, sampleRate);
1663
1707
  currentChunkIndex = newChunkIdx;
1708
+ nextChunkScheduledIndex = null;
1664
1709
  scheduler.handleSeek(newChunkIdx);
1665
1710
  const chunk = chunks[newChunkIdx];
1666
1711
  if (chunk && chunk.state === "ready") {
@@ -1677,10 +1722,16 @@ function createStretcherEngine(ctx, buffer, options) {
1677
1722
  const clampedOffset = Math.min(Math.max(0, bufferOffset), audioBuf.duration - 1e-3);
1678
1723
  chunkPlayer.handleSeek(audioBuf, clampedOffset);
1679
1724
  }
1725
+ } else if (phase === "paused") {
1726
+ bufferingResumePosition = clamped;
1680
1727
  }
1681
1728
  } else {
1682
- bufferingResumePosition = clamped;
1683
- enterBuffering("seek");
1729
+ if (phase === "paused") {
1730
+ bufferingResumePosition = clamped;
1731
+ } else {
1732
+ bufferingResumePosition = clamped;
1733
+ enterBuffering("seek");
1734
+ }
1684
1735
  }
1685
1736
  }
1686
1737
  function stop() {
@@ -1691,18 +1742,39 @@ function createStretcherEngine(ctx, buffer, options) {
1691
1742
  function setLoop(value) {
1692
1743
  isLooping = value;
1693
1744
  }
1745
+ let tempoDebounceTimer = null;
1694
1746
  function setTempo(newTempo) {
1695
1747
  if (disposed || phase === "ended" || newTempo === currentTempo) return;
1696
- bufferingResumePosition = getPositionInOriginalBuffer();
1697
- currentChunkIndex = getChunkIndexForTime(chunks, bufferingResumePosition, sampleRate);
1748
+ if (phase === "paused") {
1749
+ if (!pendingTempoChange) {
1750
+ bufferingResumePosition = getPositionInOriginalBuffer();
1751
+ }
1752
+ currentTempo = newTempo;
1753
+ pendingTempoChange = true;
1754
+ return;
1755
+ }
1756
+ const isFirstInBurst = tempoDebounceTimer === null;
1757
+ if (isFirstInBurst) {
1758
+ bufferingResumePosition = getPositionInOriginalBuffer();
1759
+ currentChunkIndex = getChunkIndexForTime(chunks, bufferingResumePosition, sampleRate);
1760
+ enterBuffering("tempo-change");
1761
+ }
1698
1762
  currentTempo = newTempo;
1699
- enterBuffering("tempo-change");
1700
- scheduler.updatePriorities(currentChunkIndex);
1701
- scheduler.handleTempoChange(newTempo);
1763
+ if (tempoDebounceTimer !== null) clearTimeout(tempoDebounceTimer);
1764
+ tempoDebounceTimer = setTimeout(() => {
1765
+ tempoDebounceTimer = null;
1766
+ if (disposed || phase === "ended") return;
1767
+ scheduler.updatePriorities(currentChunkIndex);
1768
+ scheduler.handleTempoChange(currentTempo);
1769
+ }, 50);
1702
1770
  }
1703
1771
  function dispose() {
1704
1772
  if (disposed) return;
1705
1773
  disposed = true;
1774
+ if (tempoDebounceTimer !== null) {
1775
+ clearTimeout(tempoDebounceTimer);
1776
+ tempoDebounceTimer = null;
1777
+ }
1706
1778
  chunkPlayer.dispose();
1707
1779
  scheduler.dispose();
1708
1780
  workerManager.terminate();
@@ -1726,5 +1798,6 @@ function createStretcherEngine(ctx, buffer, options) {
1726
1798
  }
1727
1799
 
1728
1800
  exports.createStretcherEngine = createStretcherEngine;
1729
- //# sourceMappingURL=chunk-SIMLANWE.cjs.map
1730
- //# sourceMappingURL=chunk-SIMLANWE.cjs.map
1801
+ exports.trimOverlap = trimOverlap;
1802
+ //# sourceMappingURL=chunk-G37HMZEX.cjs.map
1803
+ //# sourceMappingURL=chunk-G37HMZEX.cjs.map