@audio/stretch-psola 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,25 @@
1
+ MIT License
2
+
3
+ Copyright (c) Dmitry Iv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ This work is offered under the Krishnized License (https://github.com/krishnized/license).
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # @audio/stretch-psola
2
+
3
+ Pitch-Synchronous Overlap-Add. Detects pitch period via autocorrelation, windows grains at pitch-cycle boundaries, then re-spaces them — no phase discontinuities at overlaps.
4
+
5
+ ```js
6
+ import psola from '@audio/stretch-psola'
7
+
8
+ psola(data, { factor: 1.5 })
9
+ psola(data, { factor: 2, minFreq: 100, maxFreq: 400 }) // male voice range
10
+ ```
11
+
12
+ | Param | Default | |
13
+ |---|---|---|
14
+ | `factor` | `1` | Time stretch ratio |
15
+ | `sampleRate` | `44100` | For pitch detection range |
16
+ | `minFreq` | `80` | Lowest expected pitch (Hz) |
17
+ | `maxFreq` | `500` | Highest expected pitch (Hz) |
18
+
19
+ Falls through to WSOLA on unvoiced/polyphonic frames (autocorrelation peak < 0.72).
20
+
21
+ **Use when:** speech, solo vocals, monophonic instruments, factors 0.5–2×.<br>
22
+ **Not for:** polyphonic material, extreme ratios (>2× causes gaps).
23
+
24
+ Part of [`@audio/stretch`](../..).
25
+
26
+ ## License
27
+
28
+ [MIT](./LICENSE) · [ॐ](https://github.com/krishnized/license/)
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@audio/stretch-psola",
3
+ "version": "1.0.0",
4
+ "description": "Pitch-Synchronous Overlap-Add — time-domain stretch for speech and monophonic instruments",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "psola.js",
8
+ "types": "psola.d.ts",
9
+ "exports": {
10
+ ".": "./psola.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "psola.js",
15
+ "psola.d.ts",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "dependencies": {
20
+ "@audio/stretch-core": "^1.0.0",
21
+ "@audio/stretch-wsola": "^1.0.0"
22
+ },
23
+ "keywords": [
24
+ "audio",
25
+ "dsp",
26
+ "time-stretch",
27
+ "psola",
28
+ "pitch-synchronous",
29
+ "speech",
30
+ "vocals"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "license": "MIT",
36
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
37
+ "homepage": "https://github.com/audiojs/stretch/tree/main/packages/stretch-psola",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/audiojs/stretch.git",
41
+ "directory": "packages/stretch-psola"
42
+ },
43
+ "bugs": "https://github.com/audiojs/stretch/issues",
44
+ "engines": {
45
+ "node": ">=18"
46
+ }
47
+ }
package/psola.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import type { StreamWriter } from '@audio/stretch-core'
2
+
3
+ export interface PsolaOpts {
4
+ factor?: number
5
+ sampleRate?: number
6
+ minFreq?: number
7
+ maxFreq?: number
8
+ /** Pitch-contour sampling interval in samples (default max(12, minPeriod*0.75)) */
9
+ pitchHop?: number
10
+ }
11
+
12
+ declare const psola: {
13
+ (data: Float32Array, opts?: PsolaOpts): Float32Array
14
+ (opts?: PsolaOpts): StreamWriter
15
+ }
16
+ export default psola
package/psola.js ADDED
@@ -0,0 +1,574 @@
1
+ /**
2
+ * Pitch-Synchronous Overlap-Add — time-domain stretch for speech/monophonic signals.
3
+ * Detects pitch via autocorrelation, windows grains at pitch cycle boundaries.
4
+ * Falls back to WSOLA on unvoiced/polyphonic segments.
5
+ */
6
+
7
+ import wsola from '@audio/stretch-wsola'
8
+ import { clamp, normalize, writer, PI2 } from '@audio/stretch-core'
9
+
10
+ let _corr = new Float64Array(0)
11
+ function detectPeriodRange(data, pos, minLag, maxLag, prevPeriod) {
12
+ if (minLag > maxLag) return { period: 0, score: 0 }
13
+
14
+ // Reused scratch: every read this call is confined to [minLag, maxLag], all written this call.
15
+ if (_corr.length < maxLag + 2) _corr = new Float64Array(maxLag + 2)
16
+ let corr = _corr
17
+ let n = maxLag
18
+ // e1 = Σ data[pos+i]² — depends only on the analysis window, not lag. Hoist it out.
19
+ let e1 = 0
20
+ for (let i = 0; i < n; i++) { let a = data[pos + i]; e1 += a * a }
21
+ for (let lag = minLag; lag <= maxLag; lag++) {
22
+ let sum = 0, e2 = 0
23
+ for (let i = 0; i < n; i++) {
24
+ let b = data[pos + i + lag]
25
+ sum += data[pos + i] * b
26
+ e2 += b * b
27
+ }
28
+ let d = Math.sqrt(e1 * e2)
29
+ corr[lag] = d > 1e-10 ? sum / d : 0
30
+ }
31
+
32
+ let best = 0
33
+ let bestScore = 0
34
+ let bestMetric = -Infinity
35
+ for (let lag = minLag + 1; lag < maxLag; lag++) {
36
+ if (corr[lag] < 0.3 || corr[lag] < corr[lag - 1] || corr[lag] < corr[lag + 1]) continue
37
+
38
+ let score = corr[lag]
39
+ let metric = score
40
+ if (prevPeriod > 0) metric += 0.18 * Math.max(-1, 1 - Math.abs(Math.log(lag / prevPeriod)))
41
+
42
+ let chosen = lag
43
+ let doubled = lag * 2
44
+ if (doubled < maxLag && corr[doubled] >= score * 0.88 && corr[doubled] >= corr[doubled - 1] && corr[doubled] >= corr[doubled + 1]) {
45
+ let doubledMetric = corr[doubled]
46
+ if (prevPeriod > 0) doubledMetric += 0.18 * Math.max(-1, 1 - Math.abs(Math.log(doubled / prevPeriod)))
47
+ if (doubledMetric > metric) {
48
+ chosen = doubled
49
+ score = corr[doubled]
50
+ metric = doubledMetric
51
+ }
52
+ }
53
+
54
+ if (metric > bestMetric) {
55
+ bestMetric = metric
56
+ bestScore = score
57
+ best = chosen
58
+ }
59
+ }
60
+
61
+ if (!best) {
62
+ let bestVal = -Infinity
63
+ for (let lag = minLag; lag <= maxLag; lag++) {
64
+ if (corr[lag] > bestVal) {
65
+ bestVal = corr[lag]
66
+ best = lag
67
+ bestScore = corr[lag]
68
+ }
69
+ }
70
+ // A raw max with no genuine interior turning point is credible only if
71
+ // correlation has already turned over at the edge — still climbing into
72
+ // minLag/maxLag means the true period lies outside the searched range.
73
+ if ((best === minLag && corr[minLag] > corr[minLag + 1]) ||
74
+ (best === maxLag && corr[maxLag] > corr[maxLag - 1])) return { period: 0, score: 0 }
75
+ }
76
+
77
+ if (bestScore <= 0.35 || !best) return { period: 0, score: Math.max(0, bestScore) }
78
+
79
+ let period = best
80
+ if (best > minLag && best < maxLag) {
81
+ let ym1 = corr[best - 1]
82
+ let y0 = corr[best]
83
+ let yp1 = corr[best + 1]
84
+ let denom = ym1 - 2 * y0 + yp1
85
+ if (Math.abs(denom) > 1e-8) period += clamp(0.5 * (ym1 - yp1) / denom, -0.5, 0.5)
86
+ }
87
+
88
+ return { period, score: bestScore }
89
+ }
90
+
91
+ function reuseContourSample(cache, absCenter, hop, state) {
92
+ if (!cache?.positions?.length) return null
93
+
94
+ let positions = cache.positions
95
+ let idx = state?.index || 0
96
+ while (idx + 1 < positions.length && positions[idx + 1] <= absCenter) idx++
97
+ if (state) state.index = idx
98
+
99
+ let best = -1
100
+ let bestDist = Infinity
101
+ let start = Math.max(0, idx - 1)
102
+ let end = Math.min(positions.length - 1, idx + 1)
103
+ for (let i = start; i <= end; i++) {
104
+ let dist = Math.abs(positions[i] - absCenter)
105
+ if (dist < bestDist) {
106
+ bestDist = dist
107
+ best = i
108
+ }
109
+ }
110
+
111
+ if (best < 0 || bestDist > hop * 0.45) return null
112
+ return {
113
+ period: cache.periods[best],
114
+ score: cache.scores[best],
115
+ voiced: cache.voiced[best],
116
+ }
117
+ }
118
+
119
+ function detectPeriod(data, pos, minP, maxP, end, prevPeriod = 0) {
120
+ if (pos + maxP * 2 > end) return { period: 0, score: 0 }
121
+
122
+ if (prevPeriod > 0) {
123
+ let localMin = clamp(Math.floor(prevPeriod * 0.78), minP, maxP)
124
+ let localMax = clamp(Math.ceil(prevPeriod * 1.28), minP, maxP)
125
+ let local = detectPeriodRange(data, pos, localMin, localMax, prevPeriod)
126
+ if (local.score >= 0.58) return local
127
+ }
128
+
129
+ return detectPeriodRange(data, pos, minP, maxP, prevPeriod)
130
+ }
131
+
132
+ function peakNear(data, center, radius) {
133
+ let start = Math.max(1, center - radius)
134
+ let end = Math.min(data.length - 2, center + radius)
135
+ let best = clamp(Math.round(center), start, end)
136
+ let bestVal = -Infinity
137
+ for (let i = start; i <= end; i++) {
138
+ let v = Math.abs(data[i])
139
+ if (v >= Math.abs(data[i - 1]) && v >= Math.abs(data[i + 1])) {
140
+ let score = v - 0.08 * Math.abs(i - center) / Math.max(1, radius)
141
+ if (score > bestVal) {
142
+ best = i
143
+ bestVal = score
144
+ }
145
+ }
146
+ }
147
+ if (bestVal > -Infinity) return best
148
+
149
+ for (let i = start; i <= end; i++) {
150
+ let score = Math.abs(data[i]) - 0.08 * Math.abs(i - center) / Math.max(1, radius)
151
+ if (score > bestVal) {
152
+ best = i
153
+ bestVal = score
154
+ }
155
+ }
156
+ return best
157
+ }
158
+
159
+ function smoothPeriods(periods, voiced, defP) {
160
+ if (!periods.length) return periods
161
+
162
+ let out = periods.slice()
163
+ let scratch = new Float64Array(5)
164
+ for (let pass = 0; pass < 2; pass++) {
165
+ let next = out.slice()
166
+ for (let i = 0; i < out.length; i++) {
167
+ if (!voiced[i]) continue
168
+ let c = 0
169
+ for (let k = Math.max(0, i - 2); k <= Math.min(out.length - 1, i + 2); k++) {
170
+ if (voiced[k]) scratch[c++] = out[k]
171
+ }
172
+ if (c < 3) continue
173
+ scratch.subarray(0, c).sort()
174
+ let med = scratch[c >> 1]
175
+ next[i] = clamp(0.6 * out[i] + 0.4 * med, med * 0.75, med * 1.35)
176
+ }
177
+ out = next
178
+ }
179
+
180
+ let firstVoiced = voiced.findIndex((v) => v)
181
+ let seed = firstVoiced >= 0 ? out[firstVoiced] : defP
182
+ let prev = seed
183
+ for (let i = 0; i < out.length; i++) {
184
+ if (voiced[i]) {
185
+ out[i] = clamp(out[i], prev * 0.8, prev * 1.25)
186
+ prev = out[i]
187
+ }
188
+ else out[i] = prev || seed
189
+ }
190
+
191
+ let next = prev || seed
192
+ for (let i = out.length - 1; i >= 0; i--) {
193
+ if (voiced[i]) next = out[i]
194
+ else out[i] = next || seed
195
+ }
196
+
197
+ return out
198
+ }
199
+
200
+ function pitchContour(data, minP, maxP, defP, opts = {}) {
201
+ let start = maxP * 2
202
+ let end = data.length - maxP * 2
203
+ if (end <= start) return null
204
+
205
+ let hop = opts.pitchHop ?? Math.max(12, Math.floor(minP * 0.75))
206
+ let periods = []
207
+ let scores = []
208
+ let voiced = []
209
+ let positions = []
210
+ let prevPeriod = defP
211
+ let cacheState = { index: 0 }
212
+ let segmentOffset = opts.segmentOffset || 0
213
+
214
+ // Early bailout: after warmup, if voicing rate is very low the signal is likely
215
+ // polyphonic or noisy and the full contour scan is wasted work (caller falls
216
+ // through to WSOLA). Check every checkInterval frames; bail when <15% voiced
217
+ // after ≥warmup frames.
218
+ let warmup = 16
219
+ let checkInterval = 8
220
+ let voicedCount = 0
221
+
222
+ for (let center = start; center <= end; center += hop) {
223
+ let absCenter = segmentOffset + center
224
+ let cached = reuseContourSample(opts.contourCache, absCenter, hop, cacheState)
225
+ let period, score, isVoiced
226
+ if (cached) {
227
+ period = cached.period
228
+ score = cached.score
229
+ isVoiced = cached.voiced
230
+ } else {
231
+ ({ period, score } = detectPeriod(data, center - maxP, minP, maxP, data.length, prevPeriod))
232
+ isVoiced = score >= 0.72 && period > 0
233
+ }
234
+ periods.push(isVoiced ? period : prevPeriod)
235
+ scores.push(score)
236
+ voiced.push(isVoiced)
237
+ positions.push(absCenter)
238
+ if (isVoiced) { prevPeriod = period; voicedCount++ }
239
+
240
+ if (periods.length >= warmup && periods.length % checkInterval === 0) {
241
+ if (voicedCount < periods.length * 0.15) break
242
+ }
243
+ }
244
+
245
+ // If voicing rate is below the 20% threshold that psolaBatchCore uses, skip
246
+ // the expensive smoothPeriods + marks work — caller falls through to WSOLA.
247
+ if (voicedCount < Math.max(4, periods.length * 0.15)) return null
248
+
249
+ return { start, hop, periods: smoothPeriods(periods, voiced, defP), scores, voiced, positions }
250
+ }
251
+
252
+ function periodAt(contour, pos) {
253
+ let x = (pos - contour.start) / contour.hop
254
+ if (x <= 0) return contour.periods[0]
255
+ if (x >= contour.periods.length - 1) return contour.periods[contour.periods.length - 1]
256
+ let i = Math.floor(x)
257
+ let frac = x - i
258
+ return contour.periods[i] * (1 - frac) + contour.periods[i + 1] * frac
259
+ }
260
+
261
+ function voicedAt(contour, pos) {
262
+ let i = clamp(Math.round((pos - contour.start) / contour.hop), 0, contour.voiced.length - 1)
263
+ return contour.voiced[i] && contour.scores[i] >= 0.4
264
+ }
265
+
266
+ function voicedWeight(contour, index) {
267
+ if (!contour.voiced[index]) return 0
268
+
269
+ let sum = 0
270
+ let count = 0
271
+ for (let k = Math.max(0, index - 1); k <= Math.min(contour.scores.length - 1, index + 1); k++) {
272
+ if (!contour.voiced[k]) continue
273
+ sum += clamp((contour.scores[k] - 0.34) / 0.18, 0, 1)
274
+ count++
275
+ }
276
+
277
+ return count ? sum / count : 0
278
+ }
279
+
280
+ // Precomputed per-index weights → O(1) lerp per output sample in the blend loop.
281
+ function voicedWeights(contour) {
282
+ let w = new Float64Array(contour.scores.length)
283
+ for (let i = 0; i < w.length; i++) w[i] = voicedWeight(contour, i)
284
+ return w
285
+ }
286
+
287
+ function voicedWeightAt(contour, weights, pos) {
288
+ let x = (pos - contour.start) / contour.hop
289
+ if (x <= 0) return weights[0]
290
+ if (x >= weights.length - 1) return weights[weights.length - 1]
291
+ let i = Math.floor(x)
292
+ let frac = x - i
293
+ return weights[i] * (1 - frac) + weights[i + 1] * frac
294
+ }
295
+
296
+ function findAnchor(contour) {
297
+ for (let i = 1; i < contour.voiced.length - 1; i++) {
298
+ if (contour.voiced[i - 1] && contour.voiced[i] && contour.voiced[i + 1]) return i
299
+ }
300
+
301
+ let best = -1
302
+ let bestScore = 0
303
+ for (let i = 0; i < contour.voiced.length; i++) {
304
+ if (contour.voiced[i] && contour.scores[i] > bestScore) {
305
+ best = i
306
+ bestScore = contour.scores[i]
307
+ }
308
+ }
309
+ return best
310
+ }
311
+
312
+ function marks(data, contour, minP, maxP) {
313
+ let anchorIdx = findAnchor(contour)
314
+ if (anchorIdx < 0) return { markPos: [], periods: [], voiced: [] }
315
+
316
+ let anchorCenter = contour.start + anchorIdx * contour.hop
317
+ let anchorPeriod = periodAt(contour, anchorCenter)
318
+ let anchorMark = peakNear(data, anchorCenter, Math.max(4, Math.floor(anchorPeriod * 0.35)))
319
+
320
+ let headMarks = []
321
+ let headPeriods = []
322
+ let headVoiced = []
323
+ let pos = anchorMark
324
+ while (pos > minP) {
325
+ let period = periodAt(contour, pos)
326
+ let predicted = pos - period
327
+ if (predicted <= 0) break
328
+ let nextPeriod = periodAt(contour, predicted)
329
+ let isVoiced = voicedAt(contour, predicted)
330
+ let radius = Math.max(4, Math.floor(nextPeriod * 0.35))
331
+ let mark = isVoiced ? peakNear(data, predicted, radius) : Math.round(predicted)
332
+ let minStep = Math.max(1, Math.floor(nextPeriod * 0.55))
333
+ let maxStep = Math.max(minStep + 1, Math.ceil(nextPeriod * 1.8))
334
+ let step = pos - mark
335
+ if (step < minStep) mark = pos - minStep
336
+ if (step > maxStep) mark = pos - maxStep
337
+ if (mark <= 0 || mark >= pos) break
338
+ headMarks.push(mark)
339
+ headPeriods.push(nextPeriod)
340
+ headVoiced.push(isVoiced)
341
+ pos = mark
342
+ }
343
+
344
+ let markPos = headMarks.reverse()
345
+ let periods = headPeriods.reverse()
346
+ let voiced = headVoiced.reverse()
347
+
348
+ markPos.push(anchorMark)
349
+ periods.push(anchorPeriod)
350
+ voiced.push(true)
351
+
352
+ pos = anchorMark
353
+ while (pos + minP < data.length) {
354
+ let period = periodAt(contour, pos)
355
+ let predicted = pos + period
356
+ if (predicted + minP >= data.length) break
357
+ let nextPeriod = periodAt(contour, predicted)
358
+ let isVoiced = voicedAt(contour, predicted)
359
+ let radius = Math.max(4, Math.floor(nextPeriod * 0.35))
360
+ let mark = isVoiced ? peakNear(data, predicted, radius) : Math.round(predicted)
361
+ let minStep = Math.max(1, Math.floor(nextPeriod * 0.55))
362
+ let maxStep = Math.max(minStep + 1, Math.ceil(nextPeriod * 1.8))
363
+ let step = mark - pos
364
+ if (step < minStep) mark = pos + minStep
365
+ if (step > maxStep) mark = pos + maxStep
366
+ if (mark <= pos || mark >= data.length) break
367
+ markPos.push(mark)
368
+ periods.push(nextPeriod)
369
+ voiced.push(isVoiced)
370
+ pos = mark
371
+ }
372
+
373
+ return { markPos, periods, voiced }
374
+ }
375
+
376
+ function addGrain(data, srcPos, left, right, out, norm, dstPos) {
377
+ left = Math.max(1, Math.round(left))
378
+ right = Math.max(1, Math.round(right))
379
+ for (let i = -left; i < right; i++) {
380
+ let si = srcPos + i
381
+ let di = dstPos + i
382
+ if (si < 0 || si >= data.length || di < 0 || di >= out.length) continue
383
+ // two half-Hann lobes meeting at the mark: window peak stays pitch-synchronous
384
+ // even for asymmetric grains (left !== right)
385
+ let w = i < 0 ? 0.5 * (1 - Math.cos(Math.PI * (i + left) / left)) : 0.5 * (1 + Math.cos(Math.PI * i / right))
386
+ out[di] += data[si] * w
387
+ norm[di] += w
388
+ }
389
+ }
390
+
391
+ function render(data, outLen, factor, markPos, periods, voiced, minP, maxP) {
392
+ let out = new Float32Array(outLen)
393
+ let norm = new Float32Array(outLen)
394
+ if (!markPos.length) return { out, norm }
395
+
396
+ let synPos = Math.round(markPos[0] * factor)
397
+ let cursor = 0
398
+ let last = markPos.length - 1
399
+
400
+ while (synPos < outLen) {
401
+ let srcTime = synPos / factor
402
+ if (srcTime > markPos[last] + periods[last]) break
403
+
404
+ while (cursor + 1 < markPos.length && markPos[cursor + 1] <= srcTime) cursor++
405
+
406
+ let best = cursor
407
+ if (cursor + 1 < markPos.length && Math.abs(markPos[cursor + 1] - srcTime) < Math.abs(markPos[cursor] - srcTime)) best = cursor + 1
408
+
409
+ let left = best > 0 ? markPos[best] - markPos[best - 1] : periods[best]
410
+ let right = best < last ? markPos[best + 1] - markPos[best] : periods[best]
411
+ left = clamp(left, minP, maxP * 2)
412
+ right = clamp(right, minP, maxP * 2)
413
+
414
+ addGrain(data, markPos[best], left, right, out, norm, Math.round(synPos))
415
+
416
+ let step = voiced[best] ? periods[best] : 0.5 * (left + right)
417
+ synPos += clamp(step, minP * 0.75, maxP * 1.25)
418
+ }
419
+
420
+ return { out, norm }
421
+ }
422
+
423
+ function psolaBatchCore(data, opts) {
424
+ let factor = opts?.factor ?? 1
425
+ if (factor === 1) return { out: new Float32Array(data), contour: null }
426
+
427
+ let sr = opts?.sampleRate || 44100
428
+ let minP = Math.floor(sr / (opts?.maxFreq || 500))
429
+ let maxP = Math.ceil(sr / (opts?.minFreq || 80))
430
+ let defP = Math.round((minP + maxP) / 2)
431
+
432
+ let n = data.length
433
+ let outLen = Math.round(n * factor)
434
+ if (n < maxP * 6) return { out: wsola(data, { factor }), contour: null }
435
+
436
+ let contour = pitchContour(data, minP, maxP, defP, {
437
+ pitchHop: opts?.pitchHop,
438
+ contourCache: opts?.contourCache,
439
+ segmentOffset: opts?.segmentOffset || 0,
440
+ })
441
+ if (!contour) return { out: wsola(data, { factor }), contour: null }
442
+
443
+ let { markPos, periods, voiced } = marks(data, contour, minP, maxP)
444
+ if (markPos.length < 4) return { out: wsola(data, { factor }), contour }
445
+
446
+ let voicedCount = 0
447
+ for (let i = 0; i < voiced.length; i++) if (voiced[i]) voicedCount++
448
+ if (voicedCount < Math.max(4, voiced.length * 0.2)) return { out: wsola(data, { factor }), contour }
449
+
450
+ let { out, norm } = render(data, outLen, factor, markPos, periods, voiced, minP, maxP)
451
+ let allEmpty = true
452
+ for (let i = 0; i < norm.length; i++) if (norm[i] > 1e-8) { allEmpty = false; break }
453
+ if (allEmpty) return { out: wsola(data, { factor }), contour }
454
+
455
+ normalize(out, norm)
456
+
457
+ // Blend WSOLA in weakly voiced regions where pitch-synchronous grains sound brittle
458
+ if (voicedCount < voiced.length * 0.95) {
459
+ let noise = wsola(data, { factor })
460
+ let weights = voicedWeights(contour)
461
+ for (let i = 0; i < outLen; i++) {
462
+ let weight = voicedWeightAt(contour, weights, i / factor)
463
+ out[i] = out[i] * weight + noise[i] * (1 - weight)
464
+ }
465
+ }
466
+
467
+ return { out, contour }
468
+ }
469
+
470
+ function psolaBatch(data, opts) {
471
+ return psolaBatchCore(data, opts).out
472
+ }
473
+
474
+ function psolaStream(opts) {
475
+ let factor = opts?.factor ?? 1
476
+ let sr = opts?.sampleRate || 44100
477
+ let maxP = Math.ceil(sr / (opts?.minFreq || 80))
478
+ let batchOpts = { factor, sampleRate: sr, minFreq: opts?.minFreq, maxFreq: opts?.maxFreq }
479
+
480
+ let segLen = Math.max(maxP * 12, 8192)
481
+ let advance = Math.max(maxP * 8, 4096)
482
+ let outOlap = Math.round((segLen - advance) * factor)
483
+ let pitchHop = Math.max(12, Math.floor((Math.floor(sr / (opts?.maxFreq || 500))) * 0.75))
484
+
485
+ let inBuf = new Float32Array(segLen * 2)
486
+ let inLen = 0
487
+ let tail = null
488
+ let streamOffset = 0
489
+ let contourCache = null
490
+
491
+ function concat(parts) {
492
+ let n = 0
493
+ for (let p of parts) n += p.length
494
+ if (!n) return new Float32Array(0)
495
+ let out = new Float32Array(n)
496
+ let off = 0
497
+ for (let p of parts) { out.set(p, off); off += p.length }
498
+ return out
499
+ }
500
+
501
+ function blend(out, results) {
502
+ if (tail) {
503
+ let xLen = Math.min(tail.length, out.length, outOlap)
504
+ let xf = new Float32Array(xLen)
505
+ for (let i = 0; i < xLen; i++) {
506
+ let w = (i + 0.5) / xLen
507
+ xf[i] = tail[i] * (1 - w) + out[i] * w
508
+ }
509
+ results.push(xf)
510
+ let emitEnd = out.length - outOlap
511
+ if (emitEnd > xLen) results.push(new Float32Array(out.subarray(xLen, emitEnd)))
512
+ tail = emitEnd < out.length ? new Float32Array(out.subarray(Math.max(xLen, emitEnd))) : null
513
+ } else {
514
+ let emitEnd = out.length - outOlap
515
+ if (emitEnd > 0) results.push(new Float32Array(out.subarray(0, emitEnd)))
516
+ tail = new Float32Array(out.subarray(Math.max(0, emitEnd)))
517
+ }
518
+ }
519
+
520
+ return {
521
+ write(chunk) {
522
+ if (inLen + chunk.length > inBuf.length) {
523
+ let nb = new Float32Array(Math.max((inLen + chunk.length) * 2, inBuf.length * 2))
524
+ nb.set(inBuf.subarray(0, inLen))
525
+ inBuf = nb
526
+ }
527
+ inBuf.set(chunk, inLen)
528
+ inLen += chunk.length
529
+ let results = []
530
+ while (inLen >= segLen) {
531
+ let seg = new Float32Array(segLen)
532
+ seg.set(inBuf.subarray(0, segLen))
533
+ let { out, contour } = psolaBatchCore(seg, { ...batchOpts, pitchHop, contourCache, segmentOffset: streamOffset })
534
+ blend(out, results)
535
+ contourCache = contour
536
+ inBuf.copyWithin(0, advance, inLen)
537
+ inLen -= advance
538
+ streamOffset += advance
539
+ }
540
+ return concat(results)
541
+ },
542
+ flush() {
543
+ let results = []
544
+ if (inLen > 0) {
545
+ let seg = new Float32Array(inLen)
546
+ seg.set(inBuf.subarray(0, inLen))
547
+ let { out } = psolaBatchCore(seg, { ...batchOpts, pitchHop, contourCache, segmentOffset: streamOffset })
548
+ if (tail) {
549
+ let xLen = Math.min(tail.length, out.length, outOlap)
550
+ let xf = new Float32Array(xLen)
551
+ for (let i = 0; i < xLen; i++) {
552
+ let w = (i + 0.5) / xLen
553
+ xf[i] = tail[i] * (1 - w) + out[i] * w
554
+ }
555
+ results.push(xf)
556
+ if (out.length > xLen) results.push(new Float32Array(out.subarray(xLen)))
557
+ } else {
558
+ results.push(out)
559
+ }
560
+ inLen = 0
561
+ } else if (tail) {
562
+ results.push(tail)
563
+ }
564
+ tail = null
565
+ contourCache = null
566
+ return concat(results)
567
+ }
568
+ }
569
+ }
570
+
571
+ export default function psola(data, opts) {
572
+ if (!(data instanceof Float32Array)) return writer(psolaStream(data))
573
+ return psolaBatch(data, opts)
574
+ }