waa-play 0.1.0

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 (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +163 -0
  3. package/dist/adapters.cjs +28 -0
  4. package/dist/adapters.cjs.map +1 -0
  5. package/dist/adapters.d.cts +42 -0
  6. package/dist/adapters.d.ts +42 -0
  7. package/dist/adapters.js +3 -0
  8. package/dist/adapters.js.map +1 -0
  9. package/dist/buffer.cjs +24 -0
  10. package/dist/buffer.cjs.map +1 -0
  11. package/dist/buffer.d.cts +34 -0
  12. package/dist/buffer.d.ts +34 -0
  13. package/dist/buffer.js +3 -0
  14. package/dist/buffer.js.map +1 -0
  15. package/dist/chunk-2DL7CAEP.js +69 -0
  16. package/dist/chunk-2DL7CAEP.js.map +1 -0
  17. package/dist/chunk-37CPPRLV.js +24 -0
  18. package/dist/chunk-37CPPRLV.js.map +1 -0
  19. package/dist/chunk-4LNVRSTM.cjs +72 -0
  20. package/dist/chunk-4LNVRSTM.cjs.map +1 -0
  21. package/dist/chunk-5J7S6QV3.cjs +44 -0
  22. package/dist/chunk-5J7S6QV3.cjs.map +1 -0
  23. package/dist/chunk-6UTN73HG.cjs +29 -0
  24. package/dist/chunk-6UTN73HG.cjs.map +1 -0
  25. package/dist/chunk-AGP2IRC6.js +63 -0
  26. package/dist/chunk-AGP2IRC6.js.map +1 -0
  27. package/dist/chunk-C2ASIYN5.js +67 -0
  28. package/dist/chunk-C2ASIYN5.js.map +1 -0
  29. package/dist/chunk-CJJC6ASU.js +73 -0
  30. package/dist/chunk-CJJC6ASU.js.map +1 -0
  31. package/dist/chunk-CPAT75WD.cjs +60 -0
  32. package/dist/chunk-CPAT75WD.cjs.map +1 -0
  33. package/dist/chunk-CRODJ4KS.js +71 -0
  34. package/dist/chunk-CRODJ4KS.js.map +1 -0
  35. package/dist/chunk-D5CD5KQZ.cjs +72 -0
  36. package/dist/chunk-D5CD5KQZ.cjs.map +1 -0
  37. package/dist/chunk-GYH2JSCY.js +42 -0
  38. package/dist/chunk-GYH2JSCY.js.map +1 -0
  39. package/dist/chunk-HTGOHC73.cjs +69 -0
  40. package/dist/chunk-HTGOHC73.cjs.map +1 -0
  41. package/dist/chunk-LETS7FKB.js +33 -0
  42. package/dist/chunk-LETS7FKB.js.map +1 -0
  43. package/dist/chunk-M5PDY5EZ.cjs +84 -0
  44. package/dist/chunk-M5PDY5EZ.cjs.map +1 -0
  45. package/dist/chunk-PZE6HTZR.cjs +358 -0
  46. package/dist/chunk-PZE6HTZR.cjs.map +1 -0
  47. package/dist/chunk-QFFQQMU4.cjs +75 -0
  48. package/dist/chunk-QFFQQMU4.cjs.map +1 -0
  49. package/dist/chunk-QWNV2BZ5.cjs +37 -0
  50. package/dist/chunk-QWNV2BZ5.cjs.map +1 -0
  51. package/dist/chunk-RWJ4EWJT.js +356 -0
  52. package/dist/chunk-RWJ4EWJT.js.map +1 -0
  53. package/dist/chunk-T74FBKTY.js +55 -0
  54. package/dist/chunk-T74FBKTY.js.map +1 -0
  55. package/dist/chunk-TULV7V5M.cjs +1710 -0
  56. package/dist/chunk-TULV7V5M.cjs.map +1 -0
  57. package/dist/chunk-V2QX5K42.js +1708 -0
  58. package/dist/chunk-V2QX5K42.js.map +1 -0
  59. package/dist/context.cjs +24 -0
  60. package/dist/context.cjs.map +1 -0
  61. package/dist/context.d.cts +27 -0
  62. package/dist/context.d.ts +27 -0
  63. package/dist/context.js +3 -0
  64. package/dist/context.js.map +1 -0
  65. package/dist/emitter.cjs +12 -0
  66. package/dist/emitter.cjs.map +1 -0
  67. package/dist/emitter.d.cts +24 -0
  68. package/dist/emitter.d.ts +24 -0
  69. package/dist/emitter.js +3 -0
  70. package/dist/emitter.js.map +1 -0
  71. package/dist/engine-5JK2FCNL.cjs +13 -0
  72. package/dist/engine-5JK2FCNL.cjs.map +1 -0
  73. package/dist/engine-M2U4LE3F.js +4 -0
  74. package/dist/engine-M2U4LE3F.js.map +1 -0
  75. package/dist/fade.cjs +24 -0
  76. package/dist/fade.cjs.map +1 -0
  77. package/dist/fade.d.cts +21 -0
  78. package/dist/fade.d.ts +21 -0
  79. package/dist/fade.js +3 -0
  80. package/dist/fade.js.map +1 -0
  81. package/dist/index.cjs +165 -0
  82. package/dist/index.cjs.map +1 -0
  83. package/dist/index.d.cts +11 -0
  84. package/dist/index.d.ts +11 -0
  85. package/dist/index.js +12 -0
  86. package/dist/index.js.map +1 -0
  87. package/dist/nodes.cjs +48 -0
  88. package/dist/nodes.cjs.map +1 -0
  89. package/dist/nodes.d.cts +61 -0
  90. package/dist/nodes.d.ts +61 -0
  91. package/dist/nodes.js +3 -0
  92. package/dist/nodes.js.map +1 -0
  93. package/dist/play.cjs +13 -0
  94. package/dist/play.cjs.map +1 -0
  95. package/dist/play.d.cts +19 -0
  96. package/dist/play.d.ts +19 -0
  97. package/dist/play.js +4 -0
  98. package/dist/play.js.map +1 -0
  99. package/dist/scheduler.cjs +16 -0
  100. package/dist/scheduler.cjs.map +1 -0
  101. package/dist/scheduler.d.cts +39 -0
  102. package/dist/scheduler.d.ts +39 -0
  103. package/dist/scheduler.js +3 -0
  104. package/dist/scheduler.js.map +1 -0
  105. package/dist/stretcher.cjs +13 -0
  106. package/dist/stretcher.cjs.map +1 -0
  107. package/dist/stretcher.d.cts +171 -0
  108. package/dist/stretcher.d.ts +171 -0
  109. package/dist/stretcher.js +4 -0
  110. package/dist/stretcher.js.map +1 -0
  111. package/dist/synth.cjs +20 -0
  112. package/dist/synth.cjs.map +1 -0
  113. package/dist/synth.d.cts +15 -0
  114. package/dist/synth.d.ts +15 -0
  115. package/dist/synth.js +3 -0
  116. package/dist/synth.js.map +1 -0
  117. package/dist/types-DUrbEbPl.d.cts +177 -0
  118. package/dist/types-DUrbEbPl.d.ts +177 -0
  119. package/dist/waveform.cjs +20 -0
  120. package/dist/waveform.cjs.map +1 -0
  121. package/dist/waveform.d.cts +22 -0
  122. package/dist/waveform.d.ts +22 -0
  123. package/dist/waveform.js +3 -0
  124. package/dist/waveform.js.map +1 -0
  125. package/package.json +123 -0
@@ -0,0 +1,1708 @@
1
+ import { createEmitter } from './chunk-GYH2JSCY.js';
2
+
3
+ // src/stretcher/constants.ts
4
+ var CHUNK_DURATION_SEC = 8;
5
+ var OVERLAP_SEC = 0.2;
6
+ var CROSSFADE_SEC = 0.1;
7
+ var WSOLA_FRAME_SIZE = 1024;
8
+ var WSOLA_HOP_SIZE = 512;
9
+ var WSOLA_TOLERANCE = 2048;
10
+ var PRIORITY_FORWARD_WEIGHT = 1;
11
+ var PRIORITY_BACKWARD_WEIGHT = 2.5;
12
+ var CANCEL_DISTANCE_THRESHOLD = 6;
13
+ var BUFFER_HEALTHY_SEC = 30;
14
+ var BUFFER_LOW_SEC = 10;
15
+ var BUFFER_CRITICAL_SEC = 3;
16
+ var BUFFER_RESUME_SEC = 5;
17
+ var KEEP_AHEAD_CHUNKS = 19;
18
+ var KEEP_AHEAD_SECONDS = 150;
19
+ var KEEP_BEHIND_CHUNKS = 8;
20
+ var KEEP_BEHIND_SECONDS = 60;
21
+ var WORKER_POOL_SIZE = 2;
22
+ var MAX_WORKER_CRASHES = 3;
23
+ var MAX_CHUNK_RETRIES = 3;
24
+ var LOOKAHEAD_INTERVAL_MS = 200;
25
+ var LOOKAHEAD_THRESHOLD_SEC = 1.5;
26
+
27
+ // src/stretcher/chunk-splitter.ts
28
+ function splitIntoChunks(totalSamples, sampleRate, chunkDurationSec = CHUNK_DURATION_SEC, overlapSec = OVERLAP_SEC) {
29
+ if (totalSamples <= 0 || sampleRate <= 0) {
30
+ return [];
31
+ }
32
+ const chunkSamples = Math.round(chunkDurationSec * sampleRate);
33
+ const overlapSamples = Math.round(overlapSec * sampleRate);
34
+ if (chunkSamples <= 0) {
35
+ return [];
36
+ }
37
+ if (totalSamples <= chunkSamples) {
38
+ return [
39
+ {
40
+ index: 0,
41
+ state: "pending",
42
+ inputStartSample: 0,
43
+ inputEndSample: totalSamples,
44
+ overlapBefore: 0,
45
+ overlapAfter: 0,
46
+ outputBuffer: null,
47
+ outputLength: 0,
48
+ priority: 0,
49
+ retryCount: 0
50
+ }
51
+ ];
52
+ }
53
+ const chunks = [];
54
+ let start = 0;
55
+ let index = 0;
56
+ while (start < totalSamples) {
57
+ const isFirst = index === 0;
58
+ const nominalEnd = Math.min(start + chunkSamples, totalSamples);
59
+ const isLast = nominalEnd >= totalSamples;
60
+ const overlapBefore = isFirst ? 0 : Math.min(overlapSamples, start);
61
+ const overlapAfter = isLast ? 0 : Math.min(overlapSamples, totalSamples - nominalEnd);
62
+ const inputStart = start - overlapBefore;
63
+ const inputEnd = Math.min(nominalEnd + overlapAfter, totalSamples);
64
+ chunks.push({
65
+ index,
66
+ state: "pending",
67
+ inputStartSample: inputStart,
68
+ inputEndSample: inputEnd,
69
+ overlapBefore,
70
+ overlapAfter,
71
+ outputBuffer: null,
72
+ outputLength: 0,
73
+ priority: 0,
74
+ retryCount: 0
75
+ });
76
+ start = nominalEnd;
77
+ index++;
78
+ }
79
+ return chunks;
80
+ }
81
+ function extractChunkData(buffer, chunk) {
82
+ const channels = [];
83
+ const length = chunk.inputEndSample - chunk.inputStartSample;
84
+ for (let ch = 0; ch < buffer.numberOfChannels; ch++) {
85
+ const fullChannel = buffer.getChannelData(ch);
86
+ const chunkData = new Float32Array(length);
87
+ chunkData.set(
88
+ fullChannel.subarray(chunk.inputStartSample, chunk.inputEndSample)
89
+ );
90
+ channels.push(chunkData);
91
+ }
92
+ return channels;
93
+ }
94
+ function getChunkIndexForSample(chunks, sample) {
95
+ for (let i = 0; i < chunks.length; i++) {
96
+ const chunk = chunks[i];
97
+ const nominalStart = chunk.inputStartSample + chunk.overlapBefore;
98
+ const nominalEnd = chunk.inputEndSample - chunk.overlapAfter;
99
+ if (sample >= nominalStart && sample < nominalEnd) {
100
+ return i;
101
+ }
102
+ }
103
+ return Math.max(0, chunks.length - 1);
104
+ }
105
+ function getChunkIndexForTime(chunks, timeSeconds, sampleRate) {
106
+ const sample = Math.round(timeSeconds * sampleRate);
107
+ return getChunkIndexForSample(chunks, sample);
108
+ }
109
+
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
+ }
151
+ }
152
+ return bestOffset;
153
+ }
154
+
155
+ function wsolaTimeStretch(channels, tempo, sampleRate) {
156
+ if (channels.length === 0) {
157
+ return { output: [], length: 0 };
158
+ }
159
+
160
+ var inputLength = channels[0].length;
161
+ if (inputLength === 0) {
162
+ return { output: channels.map(function() { return new Float32Array(0); }), length: 0 };
163
+ }
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
+ };
174
+ }
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;
218
+ }
219
+ }
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;
233
+ }
234
+ }
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
+ }
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
+ }
254
+ }
255
+ }
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 });
278
+ } 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
+ );
283
+ }
284
+ } catch (err) {
285
+ self.postMessage({ type: "error", chunkIndex: msg.chunkIndex, error: String(err) });
286
+ }
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);
298
+ }
299
+
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);
314
+ }
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;
324
+ }
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;
330
+ }
331
+ if (response.type === "error") {
332
+ slot.busy = false;
333
+ slot.currentChunkIndex = null;
334
+ onError(response);
335
+ return;
336
+ }
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
+ });
351
+ }
352
+ if (slot.worker) {
353
+ slot.worker.onmessage = null;
354
+ slot.worker.onerror = null;
355
+ slot.worker.terminate();
356
+ slot.worker = null;
357
+ }
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?.();
368
+ }
369
+ }
370
+ };
371
+ slot.worker = worker;
372
+ }
373
+ for (let i = 0; i < poolSize; i++) {
374
+ const slot = {
375
+ worker: null,
376
+ busy: false,
377
+ currentChunkIndex: null,
378
+ crashCount: 0
379
+ };
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;
390
+ }
391
+ }
392
+ return null;
393
+ }
394
+ function findSlotByChunk(chunkIndex) {
395
+ for (const slot of slots) {
396
+ if (slot.busy && slot.currentChunkIndex === chunkIndex) {
397
+ return slot;
398
+ }
399
+ }
400
+ return null;
401
+ }
402
+ 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;
456
+ },
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
+ }
474
+ };
475
+ }
476
+
477
+ // src/stretcher/wsola.ts
478
+ function createHannWindow(size) {
479
+ const window = new Float32Array(size);
480
+ for (let i = 0; i < size; i++) {
481
+ window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (size - 1)));
482
+ }
483
+ return window;
484
+ }
485
+ function findBestOffset(ref, search, overlapSize, maxOffset) {
486
+ let bestOffset = 0;
487
+ let bestCorr = -Infinity;
488
+ const searchLen = search.length;
489
+ const refLen = ref.length;
490
+ const len = Math.min(overlapSize, refLen);
491
+ for (let offset = 0; offset <= maxOffset; offset++) {
492
+ if (offset + len > searchLen) break;
493
+ let corr = 0;
494
+ let normRef = 0;
495
+ let normSearch = 0;
496
+ for (let i = 0; i < len; i++) {
497
+ const r = ref[i];
498
+ const s = search[offset + i];
499
+ corr += r * s;
500
+ normRef += r * r;
501
+ normSearch += s * s;
502
+ }
503
+ const denom = Math.sqrt(normRef * normSearch);
504
+ const ncc = denom > 1e-10 ? corr / denom : 0;
505
+ if (ncc > bestCorr) {
506
+ bestCorr = ncc;
507
+ bestOffset = offset;
508
+ }
509
+ }
510
+ return bestOffset;
511
+ }
512
+ function wsolaTimeStretch(channels, tempo, _sampleRate, frameSize = WSOLA_FRAME_SIZE, hopSize = WSOLA_HOP_SIZE, tolerance = WSOLA_TOLERANCE) {
513
+ if (channels.length === 0) {
514
+ return { output: [], length: 0 };
515
+ }
516
+ const inputLength = channels[0].length;
517
+ if (inputLength === 0) {
518
+ return { output: channels.map(() => new Float32Array(0)), length: 0 };
519
+ }
520
+ const synthesisHop = hopSize;
521
+ const analysisHop = Math.round(hopSize * tempo);
522
+ const numFrames = Math.floor((inputLength - frameSize) / analysisHop) + 1;
523
+ if (numFrames <= 0) {
524
+ return {
525
+ output: channels.map((ch) => new Float32Array(ch)),
526
+ length: inputLength
527
+ };
528
+ }
529
+ const estimatedOutputLength = (numFrames - 1) * synthesisHop + frameSize;
530
+ const outputChannels = channels.map(
531
+ () => new Float32Array(estimatedOutputLength)
532
+ );
533
+ const windowFunc = createHannWindow(frameSize);
534
+ const normBuffer = new Float32Array(estimatedOutputLength);
535
+ const prevOutputFrame = channels.map(() => new Float32Array(frameSize));
536
+ let inputPos = 0;
537
+ let outputPos = 0;
538
+ let actualOutputLength = 0;
539
+ for (let frame = 0; frame < numFrames; frame++) {
540
+ if (inputPos + frameSize > inputLength) break;
541
+ let actualInputPos = inputPos;
542
+ if (frame > 0 && tolerance > 0) {
543
+ const searchStart = Math.max(0, inputPos - tolerance);
544
+ const searchEnd = Math.min(inputLength - frameSize, inputPos + tolerance);
545
+ const searchRange = searchEnd - searchStart;
546
+ if (searchRange > 0) {
547
+ const refChannel = prevOutputFrame[0];
548
+ const inputChannel = channels[0];
549
+ const overlapStart = frameSize - synthesisHop;
550
+ 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
+ );
559
+ const bestOffset = findBestOffset(
560
+ refSlice,
561
+ searchSlice,
562
+ overlapSize,
563
+ Math.min(searchRange, searchSlice.length - overlapSize)
564
+ );
565
+ actualInputPos = searchStart + bestOffset;
566
+ }
567
+ }
568
+ for (let ch = 0; ch < channels.length; ch++) {
569
+ const input = channels[ch];
570
+ const output = outputChannels[ch];
571
+ const prevFrame = prevOutputFrame[ch];
572
+ for (let i = 0; i < frameSize; i++) {
573
+ const inIdx = actualInputPos + i;
574
+ if (inIdx >= inputLength) break;
575
+ const outIdx = outputPos + i;
576
+ if (outIdx >= estimatedOutputLength) break;
577
+ const sample = input[inIdx];
578
+ output[outIdx] += sample * windowFunc[i];
579
+ prevFrame[i] = sample;
580
+ }
581
+ }
582
+ for (let i = 0; i < frameSize; i++) {
583
+ const outIdx = outputPos + i;
584
+ if (outIdx >= estimatedOutputLength) break;
585
+ normBuffer[outIdx] += windowFunc[i];
586
+ }
587
+ inputPos += analysisHop;
588
+ outputPos += synthesisHop;
589
+ actualOutputLength = Math.min(outputPos + frameSize, estimatedOutputLength);
590
+ }
591
+ for (let ch = 0; ch < outputChannels.length; ch++) {
592
+ const output = outputChannels[ch];
593
+ for (let i = 0; i < actualOutputLength; i++) {
594
+ const norm = normBuffer[i];
595
+ if (norm > 1e-8) {
596
+ output[i] /= norm;
597
+ }
598
+ }
599
+ }
600
+ const trimmedOutput = outputChannels.map(
601
+ (ch) => ch.subarray(0, actualOutputLength)
602
+ );
603
+ return { output: trimmedOutput, length: actualOutputLength };
604
+ }
605
+
606
+ // src/stretcher/main-thread-processor.ts
607
+ function createMainThreadProcessor(onResult, onError) {
608
+ let terminated = false;
609
+ const postTimes = /* @__PURE__ */ new Map();
610
+ let currentChunkIndex = null;
611
+ let cancelledChunks = /* @__PURE__ */ new Set();
612
+ let busy = false;
613
+ return {
614
+ postConvert(chunkIndex, inputData, tempo, sampleRate) {
615
+ if (terminated) return;
616
+ busy = true;
617
+ currentChunkIndex = chunkIndex;
618
+ postTimes.set(chunkIndex, performance.now());
619
+ setTimeout(() => {
620
+ if (terminated) return;
621
+ if (cancelledChunks.has(chunkIndex)) {
622
+ cancelledChunks.delete(chunkIndex);
623
+ busy = false;
624
+ currentChunkIndex = null;
625
+ onResult({ type: "cancelled", chunkIndex });
626
+ return;
627
+ }
628
+ try {
629
+ const result = wsolaTimeStretch(inputData, tempo, sampleRate);
630
+ if (cancelledChunks.has(chunkIndex)) {
631
+ cancelledChunks.delete(chunkIndex);
632
+ busy = false;
633
+ currentChunkIndex = null;
634
+ onResult({ type: "cancelled", chunkIndex });
635
+ return;
636
+ }
637
+ busy = false;
638
+ currentChunkIndex = null;
639
+ onResult({
640
+ type: "result",
641
+ chunkIndex,
642
+ outputData: result.output,
643
+ outputLength: result.length
644
+ });
645
+ } catch (err) {
646
+ busy = false;
647
+ currentChunkIndex = null;
648
+ onError({
649
+ type: "error",
650
+ chunkIndex,
651
+ error: String(err)
652
+ });
653
+ }
654
+ }, 0);
655
+ },
656
+ cancelCurrent() {
657
+ if (terminated) return;
658
+ if (currentChunkIndex !== null) {
659
+ cancelledChunks.add(currentChunkIndex);
660
+ }
661
+ },
662
+ cancelChunk(chunkIndex) {
663
+ if (terminated) return;
664
+ cancelledChunks.add(chunkIndex);
665
+ },
666
+ isBusy() {
667
+ return busy;
668
+ },
669
+ hasCapacity() {
670
+ return !busy;
671
+ },
672
+ getCurrentChunkIndex() {
673
+ return currentChunkIndex;
674
+ },
675
+ getLastPostTime() {
676
+ let latest = null;
677
+ for (const t of postTimes.values()) {
678
+ if (latest === null || t > latest) {
679
+ latest = t;
680
+ }
681
+ }
682
+ return latest;
683
+ },
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;
787
+ },
788
+ toArray() {
789
+ return [...heap];
790
+ }
791
+ };
792
+ }
793
+
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;
824
+ }
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
+ }
847
+ }
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
+ );
868
+ }
869
+ }
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();
892
+ }
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();
904
+ }
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
+ })
914
+ };
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
+ }
951
+ function start(playheadIndex) {
952
+ updatePriorities(playheadIndex);
953
+ dispatchNext();
954
+ }
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
+
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]);
1010
+ }
1011
+ through[through.length - 1].connect(destination);
1012
+ } else {
1013
+ node.connect(destination);
1014
+ }
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 {
1029
+ }
1030
+ currentSource.disconnect();
1031
+ currentSource = null;
1032
+ }
1033
+ if (currentGain) {
1034
+ currentGain.disconnect();
1035
+ currentGain = null;
1036
+ }
1037
+ }
1038
+ function stopNextSource() {
1039
+ if (nextSource) {
1040
+ nextSource.onended = null;
1041
+ try {
1042
+ nextSource.stop();
1043
+ } catch {
1044
+ }
1045
+ nextSource.disconnect();
1046
+ nextSource = null;
1047
+ }
1048
+ if (nextGain) {
1049
+ nextGain.disconnect();
1050
+ nextGain = null;
1051
+ }
1052
+ }
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);
1063
+ }
1064
+ function stopLookahead() {
1065
+ if (lookaheadTimer !== null) {
1066
+ clearInterval(lookaheadTimer);
1067
+ lookaheadTimer = null;
1068
+ }
1069
+ }
1070
+ function cancelTransition() {
1071
+ if (transitionTimerId !== null) {
1072
+ clearTimeout(transitionTimerId);
1073
+ transitionTimerId = null;
1074
+ }
1075
+ }
1076
+ function getElapsedInChunk() {
1077
+ if (paused) return pausedPosition;
1078
+ if (stopped) return 0;
1079
+ return ctx.currentTime - playStartCtxTime + playStartOffset;
1080
+ }
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?.();
1095
+ }
1096
+ };
1097
+ currentSource.start(0, offsetInChunk);
1098
+ if (crossfadeSec > 0) {
1099
+ currentGain.gain.setValueCurveAtTime(fadeInCurve, ctx.currentTime, crossfadeSec);
1100
+ }
1101
+ startLookahead();
1102
+ }
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
+ }
1112
+ };
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;
1151
+ paused = false;
1152
+ }
1153
+ function stop() {
1154
+ if (stopped || disposed) return;
1155
+ stopped = true;
1156
+ paused = false;
1157
+ pausedPosition = 0;
1158
+ cancelTransition();
1159
+ stopCurrentSource();
1160
+ stopNextSource();
1161
+ stopLookahead();
1162
+ }
1163
+ function getCurrentPosition() {
1164
+ return getElapsedInChunk();
1165
+ }
1166
+ function hasNextScheduled() {
1167
+ return nextSource !== null;
1168
+ }
1169
+ return {
1170
+ playChunk,
1171
+ scheduleNext,
1172
+ hasNextScheduled,
1173
+ handleSeek,
1174
+ pause,
1175
+ resume,
1176
+ stop,
1177
+ getCurrentPosition,
1178
+ setOnChunkEnded(callback) {
1179
+ onChunkEnded = callback;
1180
+ },
1181
+ setOnNeedNext(callback) {
1182
+ onNeedNext = callback;
1183
+ },
1184
+ setOnTransition(callback) {
1185
+ onTransition = callback;
1186
+ },
1187
+ dispose() {
1188
+ if (disposed) return;
1189
+ disposed = true;
1190
+ cancelTransition();
1191
+ stopCurrentSource();
1192
+ stopNextSource();
1193
+ stopLookahead();
1194
+ }
1195
+ };
1196
+ }
1197
+
1198
+ // src/stretcher/buffer-monitor.ts
1199
+ function createBufferMonitor(options) {
1200
+ const healthySec = BUFFER_HEALTHY_SEC;
1201
+ const lowSec = BUFFER_LOW_SEC;
1202
+ const criticalSec = BUFFER_CRITICAL_SEC;
1203
+ const resumeSec = BUFFER_RESUME_SEC;
1204
+ const chunkDurSec = CHUNK_DURATION_SEC;
1205
+ function getAheadSeconds(currentChunkIndex, chunks) {
1206
+ let aheadSec = 0;
1207
+ for (let i = currentChunkIndex; i < chunks.length; i++) {
1208
+ const chunk = chunks[i];
1209
+ if (!chunk || chunk.state !== "ready") break;
1210
+ aheadSec += chunkDurSec;
1211
+ }
1212
+ return aheadSec;
1213
+ }
1214
+ function getHealth(currentChunkIndex, chunks) {
1215
+ const ahead = getAheadSeconds(currentChunkIndex, chunks);
1216
+ if (ahead >= healthySec) return "healthy";
1217
+ if (ahead >= lowSec) return "low";
1218
+ if (ahead >= criticalSec) return "critical";
1219
+ return "empty";
1220
+ }
1221
+ function shouldEnterBuffering(currentChunkIndex, chunks) {
1222
+ const ahead = getAheadSeconds(currentChunkIndex, chunks);
1223
+ if (ahead >= criticalSec) return false;
1224
+ const nextChunk = chunks[currentChunkIndex + 1];
1225
+ if (nextChunk && nextChunk.state === "ready") return false;
1226
+ const currentChunk = chunks[currentChunkIndex];
1227
+ if (!currentChunk || currentChunk.state !== "ready") return true;
1228
+ return ahead < criticalSec;
1229
+ }
1230
+ function shouldExitBuffering(currentChunkIndex, chunks) {
1231
+ const currentChunk = chunks[currentChunkIndex];
1232
+ if (!currentChunk || currentChunk.state !== "ready") return false;
1233
+ const ahead = getAheadSeconds(currentChunkIndex, chunks);
1234
+ if (ahead >= resumeSec) return true;
1235
+ const nextChunk = chunks[currentChunkIndex + 1];
1236
+ if (nextChunk && nextChunk.state === "ready") return true;
1237
+ const allReady = chunks.every(
1238
+ (c) => c.state === "ready" || c.state === "skipped"
1239
+ );
1240
+ if (allReady) return true;
1241
+ return false;
1242
+ }
1243
+ return {
1244
+ getHealth,
1245
+ getAheadSeconds,
1246
+ shouldEnterBuffering,
1247
+ shouldExitBuffering
1248
+ };
1249
+ }
1250
+
1251
+ // src/stretcher/engine.ts
1252
+ function trimOverlap(outputData, outputLength, chunk, sampleRate) {
1253
+ const inputLength = chunk.inputEndSample - chunk.inputStartSample;
1254
+ if (inputLength === 0 || outputLength === 0) {
1255
+ return { data: outputData, length: outputLength };
1256
+ }
1257
+ const ratio = outputLength / inputLength;
1258
+ const crossfadeKeep = Math.round(CROSSFADE_SEC * sampleRate);
1259
+ const overlapBeforeOutput = Math.round(chunk.overlapBefore * ratio);
1260
+ const overlapAfterOutput = Math.round(chunk.overlapAfter * ratio);
1261
+ const keepBefore = chunk.overlapBefore > 0 ? Math.min(crossfadeKeep, overlapBeforeOutput) : 0;
1262
+ const trimStart = overlapBeforeOutput - keepBefore;
1263
+ const trimEnd = overlapAfterOutput;
1264
+ const newLength = outputLength - trimStart - trimEnd;
1265
+ if (newLength <= 0) {
1266
+ return { data: outputData, length: outputLength };
1267
+ }
1268
+ return {
1269
+ data: outputData.map((ch) => ch.slice(trimStart, trimStart + newLength)),
1270
+ length: newLength
1271
+ };
1272
+ }
1273
+ function getCrossfadeStart(chunk) {
1274
+ return chunk.overlapBefore > 0 ? CROSSFADE_SEC : 0;
1275
+ }
1276
+ function createStretcherEngine(ctx, buffer, options) {
1277
+ const {
1278
+ tempo: initialTempo,
1279
+ offset = 0,
1280
+ through = [],
1281
+ destination = ctx.destination
1282
+ } = options;
1283
+ const emitter = createEmitter();
1284
+ const sampleRate = buffer.sampleRate;
1285
+ const totalDuration = buffer.duration;
1286
+ let phase = "waiting";
1287
+ let currentTempo = initialTempo;
1288
+ let disposed = false;
1289
+ let bufferingStartTime = 0;
1290
+ let currentChunkIndex = 0;
1291
+ let bufferingResumePosition = null;
1292
+ const keepAhead = Math.max(KEEP_AHEAD_CHUNKS, Math.ceil(KEEP_AHEAD_SECONDS / CHUNK_DURATION_SEC));
1293
+ const keepBehind = Math.max(KEEP_BEHIND_CHUNKS, Math.ceil(KEEP_BEHIND_SECONDS / CHUNK_DURATION_SEC));
1294
+ const chunks = splitIntoChunks(
1295
+ buffer.length,
1296
+ sampleRate,
1297
+ CHUNK_DURATION_SEC,
1298
+ OVERLAP_SEC
1299
+ );
1300
+ const monitor = createBufferMonitor();
1301
+ const poolSize = options.workerPoolSize ?? WORKER_POOL_SIZE;
1302
+ function handleWorkerResult(response) {
1303
+ if (disposed) return;
1304
+ if (response.type === "result") {
1305
+ const chunk = chunks[response.chunkIndex];
1306
+ if (chunk) {
1307
+ const postTime = workerManager.getPostTimeForChunk(response.chunkIndex);
1308
+ postTime !== null ? performance.now() - postTime : 0;
1309
+ const trimmed = trimOverlap(
1310
+ response.outputData,
1311
+ response.outputLength,
1312
+ chunk,
1313
+ sampleRate
1314
+ );
1315
+ schedulerInternal._handleResult(
1316
+ response.chunkIndex,
1317
+ trimmed.data,
1318
+ trimmed.length
1319
+ );
1320
+ }
1321
+ } else if (response.type === "cancelled") {
1322
+ const chunk = chunks[response.chunkIndex];
1323
+ if (chunk && chunk.state === "converting") {
1324
+ chunk.state = "queued";
1325
+ }
1326
+ scheduler.dispatchNext();
1327
+ }
1328
+ }
1329
+ function handleWorkerError(response) {
1330
+ if (disposed) return;
1331
+ if (response.type === "error") {
1332
+ schedulerInternal._handleError(
1333
+ response.chunkIndex,
1334
+ response.error ?? "Unknown error"
1335
+ );
1336
+ }
1337
+ }
1338
+ function switchToMainThread() {
1339
+ if (disposed) return;
1340
+ const fallback = createMainThreadProcessor(handleWorkerResult, handleWorkerError);
1341
+ workerManager.terminate();
1342
+ Object.assign(workerManager, fallback);
1343
+ }
1344
+ const workerManager = createWorkerManager(
1345
+ handleWorkerResult,
1346
+ handleWorkerError,
1347
+ void 0,
1348
+ poolSize,
1349
+ switchToMainThread
1350
+ );
1351
+ const schedulerInternal = createConversionScheduler(
1352
+ chunks,
1353
+ workerManager,
1354
+ (chunkIndex) => extractChunkData(buffer, chunks[chunkIndex]),
1355
+ sampleRate,
1356
+ currentTempo,
1357
+ { keepAheadChunks: keepAhead, keepBehindChunks: keepBehind },
1358
+ onChunkReady,
1359
+ onChunkFailed
1360
+ );
1361
+ const scheduler = schedulerInternal;
1362
+ const chunkPlayer = createChunkPlayer(ctx, {
1363
+ through,
1364
+ destination,
1365
+ crossfadeSec: CROSSFADE_SEC
1366
+ });
1367
+ chunkPlayer.setOnChunkEnded(() => {
1368
+ if (disposed || phase === "paused" || phase === "ended") return;
1369
+ advanceToNextChunk();
1370
+ });
1371
+ chunkPlayer.setOnNeedNext(() => {
1372
+ if (disposed) return;
1373
+ const nextIdx = currentChunkIndex + 1;
1374
+ if (nextIdx < chunks.length) {
1375
+ const nextChunk = chunks[nextIdx];
1376
+ if (nextChunk.state === "ready" && nextChunk.outputBuffer) {
1377
+ const audioBuffer = createAudioBufferFromChunk(nextChunk);
1378
+ if (audioBuffer) {
1379
+ const curChunk = chunks[currentChunkIndex];
1380
+ const curOutputDuration = curChunk ? curChunk.outputLength / sampleRate : 0;
1381
+ const elapsed = chunkPlayer.getCurrentPosition();
1382
+ const remaining = curOutputDuration - elapsed;
1383
+ const startTime = ctx.currentTime + Math.max(0, remaining);
1384
+ chunkPlayer.scheduleNext(audioBuffer, startTime);
1385
+ }
1386
+ }
1387
+ }
1388
+ });
1389
+ chunkPlayer.setOnTransition(() => {
1390
+ if (disposed) return;
1391
+ const nextIdx = currentChunkIndex + 1;
1392
+ if (nextIdx < chunks.length) {
1393
+ currentChunkIndex = nextIdx;
1394
+ scheduler.updatePriorities(currentChunkIndex);
1395
+ evictDistantChunks();
1396
+ }
1397
+ });
1398
+ function onChunkReady(chunkIndex) {
1399
+ if (disposed) return;
1400
+ emitter.emit("chunkready", { index: chunkIndex });
1401
+ emitProgress();
1402
+ emitBufferHealth();
1403
+ if (phase === "waiting" || phase === "buffering") {
1404
+ if (monitor.shouldExitBuffering(currentChunkIndex, chunks)) {
1405
+ exitBuffering();
1406
+ }
1407
+ }
1408
+ if (phase === "playing" && chunkIndex === currentChunkIndex + 1) {
1409
+ if (!chunkPlayer.hasNextScheduled()) {
1410
+ const curChunk = chunks[currentChunkIndex];
1411
+ const curOutputDuration = curChunk ? curChunk.outputLength / sampleRate : 0;
1412
+ const elapsed = chunkPlayer.getCurrentPosition();
1413
+ const remaining = curOutputDuration - elapsed;
1414
+ if (remaining <= LOOKAHEAD_THRESHOLD_SEC) {
1415
+ const nextChunk = chunks[chunkIndex];
1416
+ if (nextChunk && nextChunk.outputBuffer) {
1417
+ const audioBuffer = createAudioBufferFromChunk(nextChunk);
1418
+ if (audioBuffer) {
1419
+ const startTime = ctx.currentTime + Math.max(0, remaining);
1420
+ chunkPlayer.scheduleNext(audioBuffer, startTime);
1421
+ }
1422
+ }
1423
+ }
1424
+ }
1425
+ }
1426
+ const allDone = chunks.every(
1427
+ (c) => c.state === "ready" || c.state === "skipped" || c.state === "evicted"
1428
+ );
1429
+ if (allDone) {
1430
+ emitter.emit("complete", void 0);
1431
+ }
1432
+ evictDistantChunks();
1433
+ }
1434
+ function onChunkFailed(chunkIndex, error) {
1435
+ if (disposed) return;
1436
+ const chunk = chunks[chunkIndex];
1437
+ const fatal = chunk ? chunk.retryCount >= 3 : true;
1438
+ emitter.emit("error", { message: error, chunkIndex, fatal });
1439
+ }
1440
+ function createAudioBufferFromChunk(chunk) {
1441
+ if (!chunk.outputBuffer || chunk.outputLength === 0) return null;
1442
+ const numChannels = chunk.outputBuffer.length;
1443
+ const audioBuf = ctx.createBuffer(
1444
+ numChannels,
1445
+ chunk.outputLength,
1446
+ sampleRate
1447
+ );
1448
+ for (let ch = 0; ch < numChannels; ch++) {
1449
+ const channelData = chunk.outputBuffer[ch];
1450
+ audioBuf.getChannelData(ch).set(channelData.subarray(0, chunk.outputLength));
1451
+ }
1452
+ return audioBuf;
1453
+ }
1454
+ function playCurrentChunk(offsetInBuffer = 0) {
1455
+ const chunk = chunks[currentChunkIndex];
1456
+ if (!chunk || chunk.state !== "ready" || !chunk.outputBuffer) return;
1457
+ const audioBuf = createAudioBufferFromChunk(chunk);
1458
+ if (!audioBuf) return;
1459
+ chunkPlayer.playChunk(audioBuf, ctx.currentTime, offsetInBuffer);
1460
+ }
1461
+ function advanceToNextChunk() {
1462
+ const nextIdx = currentChunkIndex + 1;
1463
+ if (nextIdx >= chunks.length) {
1464
+ phase = "ended";
1465
+ chunkPlayer.stop();
1466
+ emitter.emit("ended", void 0);
1467
+ emitter.emit("bufferhealth", {
1468
+ health: monitor.getHealth(currentChunkIndex, chunks),
1469
+ aheadSeconds: monitor.getAheadSeconds(currentChunkIndex, chunks)
1470
+ });
1471
+ return;
1472
+ }
1473
+ currentChunkIndex = nextIdx;
1474
+ scheduler.updatePriorities(currentChunkIndex);
1475
+ const chunk = chunks[currentChunkIndex];
1476
+ if (chunk && chunk.state === "ready") {
1477
+ playCurrentChunk(getCrossfadeStart(chunk));
1478
+ } else {
1479
+ const nextChunk = chunks[currentChunkIndex];
1480
+ const nominalStartSample = nextChunk.inputStartSample + nextChunk.overlapBefore;
1481
+ bufferingResumePosition = nominalStartSample / sampleRate;
1482
+ enterBuffering("underrun");
1483
+ }
1484
+ evictDistantChunks();
1485
+ }
1486
+ function enterBuffering(reason) {
1487
+ if (phase === "ended") return;
1488
+ if (phase === "buffering" && reason !== "tempo-change" && reason !== "seek") return;
1489
+ phase = "buffering";
1490
+ bufferingStartTime = performance.now();
1491
+ chunkPlayer.pause();
1492
+ emitter.emit("buffering", { reason });
1493
+ }
1494
+ function exitBuffering() {
1495
+ const stallDuration = performance.now() - bufferingStartTime;
1496
+ phase = "playing";
1497
+ emitter.emit("buffered", { stallDuration });
1498
+ if (bufferingResumePosition !== null) {
1499
+ const resumePos = bufferingResumePosition;
1500
+ bufferingResumePosition = null;
1501
+ const chunk = chunks[currentChunkIndex];
1502
+ if (chunk && chunk.state === "ready" && chunk.outputBuffer) {
1503
+ const nominalStartSample = chunk.inputStartSample + chunk.overlapBefore;
1504
+ const nominalStartSec = nominalStartSample / sampleRate;
1505
+ const offsetInOriginal = resumePos - nominalStartSec;
1506
+ const offsetInOutput = Math.max(0, offsetInOriginal / currentTempo);
1507
+ const outputDurationSec = chunk.outputLength / sampleRate;
1508
+ const MIN_PLAYABLE_SEC = 0.05;
1509
+ if (outputDurationSec > 0 && offsetInOutput >= outputDurationSec - MIN_PLAYABLE_SEC) {
1510
+ advanceToNextChunk();
1511
+ return;
1512
+ }
1513
+ playCurrentChunk(getCrossfadeStart(chunk) + offsetInOutput);
1514
+ } else {
1515
+ const cfChunk = chunks[currentChunkIndex];
1516
+ const cfStart = cfChunk ? getCrossfadeStart(cfChunk) : 0;
1517
+ playCurrentChunk(cfStart);
1518
+ }
1519
+ } else {
1520
+ const chunk = chunks[currentChunkIndex];
1521
+ const cfStart = chunk ? getCrossfadeStart(chunk) : 0;
1522
+ playCurrentChunk(cfStart);
1523
+ }
1524
+ }
1525
+ function evictDistantChunks() {
1526
+ for (const chunk of chunks) {
1527
+ if (chunk.state !== "ready") continue;
1528
+ const dist = chunk.index - currentChunkIndex;
1529
+ if (dist > keepAhead || dist < -keepBehind) {
1530
+ chunk.outputBuffer = null;
1531
+ chunk.outputLength = 0;
1532
+ chunk.state = "evicted";
1533
+ }
1534
+ }
1535
+ }
1536
+ function emitProgress() {
1537
+ const readyCount = chunks.filter((c) => c.state === "ready").length;
1538
+ const total = chunks.length;
1539
+ emitter.emit("progress", {
1540
+ total,
1541
+ ready: readyCount,
1542
+ progress: total > 0 ? readyCount / total : 0
1543
+ });
1544
+ }
1545
+ function emitBufferHealth() {
1546
+ emitter.emit("bufferhealth", {
1547
+ health: monitor.getHealth(currentChunkIndex, chunks),
1548
+ aheadSeconds: monitor.getAheadSeconds(currentChunkIndex, chunks)
1549
+ });
1550
+ }
1551
+ function getPositionInOriginalBuffer() {
1552
+ if (phase === "ended") return totalDuration;
1553
+ if (phase === "waiting") return offset;
1554
+ if (phase === "buffering" && bufferingResumePosition !== null) {
1555
+ return bufferingResumePosition;
1556
+ }
1557
+ const chunk = chunks[currentChunkIndex];
1558
+ if (!chunk) return 0;
1559
+ const nominalStartSample = chunk.inputStartSample + chunk.overlapBefore;
1560
+ const nominalStartSec = nominalStartSample / sampleRate;
1561
+ const posInChunk = chunkPlayer.getCurrentPosition();
1562
+ const crossfadeOffset = chunk.overlapBefore > 0 ? CROSSFADE_SEC : 0;
1563
+ const adjustedPosInChunk = Math.max(0, posInChunk - crossfadeOffset);
1564
+ const posInOriginal = adjustedPosInChunk * currentTempo;
1565
+ return Math.min(nominalStartSec + posInOriginal, totalDuration);
1566
+ }
1567
+ function getStatus() {
1568
+ const readyCount = chunks.filter((c) => c.state === "ready").length;
1569
+ const convertingCount = chunks.filter(
1570
+ (c) => c.state === "converting"
1571
+ ).length;
1572
+ const total = chunks.length;
1573
+ return {
1574
+ phase,
1575
+ conversion: {
1576
+ total,
1577
+ ready: readyCount,
1578
+ converting: convertingCount,
1579
+ progress: total > 0 ? readyCount / total : 0
1580
+ },
1581
+ buffer: {
1582
+ health: monitor.getHealth(currentChunkIndex, chunks),
1583
+ aheadSeconds: monitor.getAheadSeconds(currentChunkIndex, chunks)
1584
+ },
1585
+ playback: {
1586
+ position: getPositionInOriginalBuffer(),
1587
+ duration: totalDuration,
1588
+ tempo: currentTempo
1589
+ }
1590
+ };
1591
+ }
1592
+ function getSnapshot() {
1593
+ const readyCount = chunks.filter((c) => c.state === "ready").length;
1594
+ const total = chunks.length;
1595
+ const convertingCount = chunks.filter(
1596
+ (c) => c.state === "converting"
1597
+ ).length;
1598
+ const windowStart = Math.max(0, currentChunkIndex - keepBehind);
1599
+ const windowEnd = Math.min(total - 1, currentChunkIndex + keepAhead);
1600
+ const windowSize = windowEnd - windowStart + 1;
1601
+ const readyInWindow = chunks.slice(windowStart, windowEnd + 1).filter((c) => c.state === "ready").length;
1602
+ return {
1603
+ tempo: currentTempo,
1604
+ converting: convertingCount > 0,
1605
+ conversionProgress: total > 0 ? readyCount / total : 0,
1606
+ bufferHealth: monitor.getHealth(currentChunkIndex, chunks),
1607
+ aheadSeconds: monitor.getAheadSeconds(currentChunkIndex, chunks),
1608
+ buffering: phase === "buffering" || phase === "waiting",
1609
+ chunkStates: chunks.map((c) => c.state),
1610
+ currentChunkIndex,
1611
+ activeWindowStart: windowStart,
1612
+ activeWindowEnd: windowEnd,
1613
+ totalChunks: total,
1614
+ windowConversionProgress: windowSize > 0 ? readyInWindow / windowSize : 0
1615
+ };
1616
+ }
1617
+ function start() {
1618
+ if (disposed) return;
1619
+ currentChunkIndex = getChunkIndexForTime(chunks, offset, sampleRate);
1620
+ bufferingResumePosition = offset;
1621
+ phase = "waiting";
1622
+ enterBuffering("initial");
1623
+ scheduler.start(currentChunkIndex);
1624
+ }
1625
+ function pause() {
1626
+ if (disposed || phase === "ended") return;
1627
+ phase = "paused";
1628
+ chunkPlayer.pause();
1629
+ }
1630
+ function resume() {
1631
+ if (disposed || phase !== "paused") return;
1632
+ const chunk = chunks[currentChunkIndex];
1633
+ if (chunk && chunk.state === "ready") {
1634
+ phase = "playing";
1635
+ chunkPlayer.resume();
1636
+ playCurrentChunk(chunkPlayer.getCurrentPosition());
1637
+ } else {
1638
+ enterBuffering("underrun");
1639
+ }
1640
+ }
1641
+ function seek(position) {
1642
+ if (disposed) return;
1643
+ const clamped = Math.max(0, Math.min(position, totalDuration));
1644
+ const newChunkIdx = getChunkIndexForTime(chunks, clamped, sampleRate);
1645
+ currentChunkIndex = newChunkIdx;
1646
+ scheduler.handleSeek(newChunkIdx);
1647
+ const chunk = chunks[newChunkIdx];
1648
+ if (chunk && chunk.state === "ready") {
1649
+ const nominalStartSample = chunk.inputStartSample + chunk.overlapBefore;
1650
+ const nominalStartSec = nominalStartSample / sampleRate;
1651
+ const offsetInOriginal = clamped - nominalStartSec;
1652
+ const offsetInOutput = offsetInOriginal / currentTempo;
1653
+ if (phase === "playing" || phase === "buffering" || phase === "waiting") {
1654
+ phase = "playing";
1655
+ const audioBuf = createAudioBufferFromChunk(chunk);
1656
+ if (audioBuf) {
1657
+ const crossfadeStart = getCrossfadeStart(chunk);
1658
+ const bufferOffset = crossfadeStart + offsetInOutput;
1659
+ const clampedOffset = Math.min(Math.max(0, bufferOffset), audioBuf.duration - 1e-3);
1660
+ chunkPlayer.handleSeek(audioBuf, clampedOffset);
1661
+ }
1662
+ }
1663
+ } else {
1664
+ bufferingResumePosition = clamped;
1665
+ enterBuffering("seek");
1666
+ }
1667
+ }
1668
+ function stop() {
1669
+ if (disposed) return;
1670
+ phase = "ended";
1671
+ chunkPlayer.stop();
1672
+ }
1673
+ function setTempo(newTempo) {
1674
+ if (disposed || phase === "ended" || newTempo === currentTempo) return;
1675
+ bufferingResumePosition = getPositionInOriginalBuffer();
1676
+ currentChunkIndex = getChunkIndexForTime(chunks, bufferingResumePosition, sampleRate);
1677
+ currentTempo = newTempo;
1678
+ enterBuffering("tempo-change");
1679
+ scheduler.updatePriorities(currentChunkIndex);
1680
+ scheduler.handleTempoChange(newTempo);
1681
+ }
1682
+ function dispose() {
1683
+ if (disposed) return;
1684
+ disposed = true;
1685
+ chunkPlayer.dispose();
1686
+ scheduler.dispose();
1687
+ workerManager.terminate();
1688
+ emitter.clear();
1689
+ }
1690
+ return {
1691
+ start,
1692
+ pause,
1693
+ resume,
1694
+ seek,
1695
+ stop,
1696
+ setTempo,
1697
+ getCurrentPosition: getPositionInOriginalBuffer,
1698
+ getStatus,
1699
+ getSnapshot,
1700
+ on: emitter.on.bind(emitter),
1701
+ off: emitter.off.bind(emitter),
1702
+ dispose
1703
+ };
1704
+ }
1705
+
1706
+ export { createStretcherEngine };
1707
+ //# sourceMappingURL=chunk-V2QX5K42.js.map
1708
+ //# sourceMappingURL=chunk-V2QX5K42.js.map