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