@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 +25 -0
- package/README.md +28 -0
- package/package.json +47 -0
- package/psola.d.ts +16 -0
- package/psola.js +574 -0
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
|
+
}
|