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