@audio/stretch-wsola 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-wsola
2
+
3
+ Waveform Similarity Overlap-Add time stretching. Search ±delta around each synthesis hop for the position that best correlates with the running output — eliminates the flanging of plain OLA, no FFT.
4
+
5
+ ```js
6
+ import wsola from '@audio/stretch-wsola'
7
+
8
+ let out = wsola(samples, { factor: 1.5 })
9
+
10
+ let write = wsola({ factor: 2 }) // streaming
11
+ write(block1)
12
+ let tail = write() // flush
13
+ ```
14
+
15
+ | Param | Default | |
16
+ |---|---|---|
17
+ | `factor` | `1` | Time stretch ratio |
18
+ | `frameSize` | `2048` | Window size |
19
+ | `hopSize` | `frameSize/4` | Hop between frames |
20
+ | `delta` | `frameSize/4` | Search range (±samples) |
21
+
22
+ **Use when:** speech, real-time, moderate ratios (0.5–2×). Polyphonic music is better served by [`@audio/stretch-pvoc-lock`](../stretch-pvoc-lock) or [`@audio/stretch-transient`](../stretch-transient).
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,45 @@
1
+ {
2
+ "name": "@audio/stretch-wsola",
3
+ "version": "1.0.0",
4
+ "description": "WSOLA time stretching — time-domain, low CPU, great for speech",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "wsola.js",
8
+ "types": "wsola.d.ts",
9
+ "exports": {
10
+ ".": "./wsola.js",
11
+ "./package.json": "./package.json"
12
+ },
13
+ "files": [
14
+ "wsola.js",
15
+ "wsola.d.ts",
16
+ "README.md",
17
+ "LICENSE"
18
+ ],
19
+ "dependencies": {
20
+ "@audio/stretch-core": "^1.0.0"
21
+ },
22
+ "keywords": [
23
+ "audio",
24
+ "dsp",
25
+ "time-stretch",
26
+ "wsola",
27
+ "tempo",
28
+ "speech"
29
+ ],
30
+ "publishConfig": {
31
+ "access": "public"
32
+ },
33
+ "license": "MIT",
34
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
35
+ "homepage": "https://github.com/audiojs/stretch/tree/main/packages/stretch-wsola",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/audiojs/stretch.git",
39
+ "directory": "packages/stretch-wsola"
40
+ },
41
+ "bugs": "https://github.com/audiojs/stretch/issues",
42
+ "engines": {
43
+ "node": ">=18"
44
+ }
45
+ }
package/wsola.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { StreamWriter } from '@audio/stretch-core'
2
+
3
+ export interface WsolaOpts {
4
+ factor?: number
5
+ frameSize?: number
6
+ hopSize?: number
7
+ delta?: number
8
+ }
9
+
10
+ declare const wsola: {
11
+ (data: Float32Array, opts?: WsolaOpts): Float32Array
12
+ (opts?: WsolaOpts): StreamWriter
13
+ }
14
+ export default wsola
package/wsola.js ADDED
@@ -0,0 +1,157 @@
1
+ import { hannWindow, writer, makeStreamBufs } from '@audio/stretch-core'
2
+
3
+ // Canonical Verhelst-Roelands WSOLA: each grain's read position maximizes cross-
4
+ // correlation with the *natural progression* of the previous grain through the
5
+ // input — i.e. data[prevRead + synHop : ...]. Correlating against the synthesis
6
+ // output (a sum of previous compromise grains) lets phase errors compound across
7
+ // grains and causes hop-rate amplitude modulation ("crumble") on polyphonic
8
+ // content. The input target is clean and gives the same result for monophonic
9
+ // signals at no extra cost.
10
+ function corrLength(frameSize, synHop) {
11
+ // Hann taper on large frames makes outer samples low-energy — halving the
12
+ // loop (≥2048 frames) cuts search cost ~33% without shifting the peak.
13
+ return frameSize >= 2048 ? frameSize >> 1 : frameSize - synHop
14
+ }
15
+
16
+ export default function wsola(data, opts) {
17
+ if (!(data instanceof Float32Array)) return writer(wsolaStream(data))
18
+
19
+ let factor = opts?.factor ?? 1
20
+ if (factor === 1) return new Float32Array(data)
21
+
22
+ let frameSize = opts?.frameSize ?? 2048
23
+ let hopSize = opts?.hopSize ?? (frameSize >> 2)
24
+ let delta = opts?.delta ?? (frameSize >> 2)
25
+
26
+ let inLen = data.length
27
+ let outLen = Math.round(inLen * factor)
28
+ let out = new Float32Array(outLen)
29
+ let norm = new Float32Array(outLen)
30
+
31
+ let synHop = hopSize
32
+ let anaHop = hopSize / factor
33
+ let win = hannWindow(frameSize)
34
+ let corrLen = corrLength(frameSize, synHop)
35
+
36
+ // Cap the read position at the last index a full real frame can start from —
37
+ // once analysis would run past it, keep re-aligning (via search) within that
38
+ // final frame instead of abandoning synthesis before outLen is covered.
39
+ let maxRead = Math.max(0, inLen - frameSize)
40
+
41
+ let anaPos = 0, synPos = 0
42
+ let prevReadPos = 0
43
+
44
+ while (synPos < outLen) {
45
+ let nomPos = Math.min(Math.round(anaPos), maxRead)
46
+ let readPos = nomPos
47
+
48
+ if (synPos > 0 && delta > 0) {
49
+ let searchStart = Math.max(0, nomPos - delta)
50
+ let searchEnd = Math.min(maxRead, nomPos + delta)
51
+
52
+ let targetStart = prevReadPos + synHop
53
+ let L = Math.min(corrLen, inLen - targetStart, inLen - searchEnd)
54
+ if (L > 0) {
55
+ let step = L > 768 ? 2 : 1
56
+ let bestCorr = -Infinity, bestS = searchStart
57
+ for (let s = searchStart; s <= searchEnd; s++) {
58
+ let corr = 0
59
+ for (let i = 0; i < L; i += step) corr += data[s + i] * data[targetStart + i]
60
+ if (corr > bestCorr) { bestCorr = corr; bestS = s }
61
+ }
62
+ readPos = bestS
63
+ }
64
+ }
65
+
66
+ for (let i = 0; i < frameSize && synPos + i < outLen; i++) {
67
+ out[synPos + i] += (readPos + i < inLen ? data[readPos + i] : 0) * win[i]
68
+ norm[synPos + i] += win[i]
69
+ }
70
+
71
+ prevReadPos = readPos
72
+ anaPos += anaHop
73
+ synPos += synHop
74
+ }
75
+
76
+ for (let i = 0; i < outLen; i++) if (norm[i] > 1e-8) out[i] /= norm[i]
77
+ return out
78
+ }
79
+
80
+ function wsolaStream(opts) {
81
+ let factor = opts?.factor ?? 1
82
+ let frameSize = opts?.frameSize ?? 2048
83
+ let hopSize = opts?.hopSize ?? (frameSize >> 2)
84
+ let delta = opts?.delta ?? (frameSize >> 2)
85
+ let win = hannWindow(frameSize)
86
+ let synHop = hopSize
87
+ let anaHop = hopSize / factor
88
+ let corrLen = corrLength(frameSize, synHop)
89
+
90
+ let st = makeStreamBufs(frameSize)
91
+ let aPos = 0
92
+ // Track absolute position of last read so the natural-progression target
93
+ // survives input compaction (st.compactIn shifts ib).
94
+ let prevReadAbs = 0
95
+ let inOffset = 0 // absolute position of ib[0]
96
+
97
+ // `final` mirrors the batch function's synPos<outLen: keep re-aligning within
98
+ // the final real frame (nomPos capped at maxRead) until analysis has nominally
99
+ // caught up with all buffered input, instead of stopping early because the
100
+ // *uncapped* aPos+frameSize no longer fits — that premature stop is correct
101
+ // behavior mid-stream (wait for more chunks) but wrong at flush (no more chunks
102
+ // are coming, so the tail must still be covered).
103
+ function run(final) {
104
+ let maxRead = Math.max(0, st.il - frameSize)
105
+ while (final ? Math.round(aPos) < st.il : Math.round(aPos) + frameSize <= st.il) {
106
+ let nomPos = Math.min(Math.round(aPos), maxRead)
107
+ let readPos = nomPos
108
+
109
+ if (st.pos > 0 && delta > 0) {
110
+ let searchS = Math.max(0, nomPos - delta)
111
+ let searchE = Math.min(maxRead, nomPos + delta)
112
+ let targetStart = (prevReadAbs - inOffset) + synHop
113
+ let L = Math.min(corrLen, st.il - targetStart, st.il - searchE)
114
+ if (targetStart >= 0 && L > 0) {
115
+ let step = L > 768 ? 2 : 1
116
+ let bestCorr = -Infinity, bestS = searchS
117
+ let ib = st.ib
118
+ for (let s = searchS; s <= searchE; s++) {
119
+ let corr = 0
120
+ for (let i = 0; i < L; i += step) corr += ib[s + i] * ib[targetStart + i]
121
+ if (corr > bestCorr) { bestCorr = corr; bestS = s }
122
+ }
123
+ readPos = bestS
124
+ }
125
+ }
126
+
127
+ st.growOut(st.pos + frameSize)
128
+ let ob = st.ob, nb = st.nb, base = st.pos, ib = st.ib
129
+ for (let i = 0; i < frameSize; i++) {
130
+ ob[base + i] += (readPos + i < st.il ? ib[readPos + i] : 0) * win[i]
131
+ nb[base + i] += win[i]
132
+ }
133
+ prevReadAbs = inOffset + readPos
134
+ aPos += anaHop
135
+ st.pos += synHop
136
+ }
137
+ let used = Math.floor(aPos)
138
+ if (used > frameSize * 2 + delta) {
139
+ let trim = used - frameSize - delta
140
+ st.compactIn(trim)
141
+ aPos -= trim
142
+ inOffset += trim
143
+ }
144
+ }
145
+
146
+ return {
147
+ write(chunk) {
148
+ st.appendIn(chunk)
149
+ run(false)
150
+ return st.take(Math.max(0, st.pos - frameSize + synHop))
151
+ },
152
+ flush() {
153
+ run(true)
154
+ return st.take(st.pos)
155
+ }
156
+ }
157
+ }