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