@audio/spectral-pvoc 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/README.md +20 -0
- package/package.json +37 -0
- package/pvoc.js +259 -0
package/README.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# @audio/spectral-pvoc
|
|
2
|
+
|
|
3
|
+
> Phase-vocoder engine — the spectral scatter/lock machinery behind pitch shifting and time stretching.
|
|
4
|
+
|
|
5
|
+
Frame-level primitives over STFT magnitude/phase arrays. Pure math, no dependencies; pair with [`fourier-transform/stft`](https://github.com/scijs/fourier-transform) (ana/syn hop) or [`@audio/stft`](https://github.com/audiojs/stft) (AMS).
|
|
6
|
+
|
|
7
|
+
```js
|
|
8
|
+
import { findPeaks, scatterLocked, lockPhase, makeFrameRatio } from '@audio/spectral-pvoc'
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| export | role |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `scatterGated(mag, phase, prevPhase, ratio, ctx, …scratch)` | Bernsee/SMB peak-gated bin scatter — pitch shift by ratio, RMS-preserving collisions |
|
|
14
|
+
| `scatterLocked(mag, phase, prevPhase, reset, peaks, ratio, ctx, …scratch)` | Laroche-Dolson rigid-ROI scatter — phase-coherent shift |
|
|
15
|
+
| `lockPhase(phase, propPhase, mag, half)` | lock non-peak bins to their peak's rotation (time-stretch coherence) |
|
|
16
|
+
| `findPeaks(mag, half)` / `nearestPeak(peaks, k)` | local-maxima peak indices + nearest lookup |
|
|
17
|
+
| `makeFrameRatio(ratio)` | scalar or time-varying ratio → per-frame resolver |
|
|
18
|
+
| `wrapPhase(p)` / `WIN_GAIN` / `PI2` | phase wrap, Hann OLA gain constant |
|
|
19
|
+
|
|
20
|
+
Used by [`@audio/shift`](https://github.com/audiojs/shift)'s pvoc/pvoc-lock/sms/formant/transient/hpss and [`@audio/stretch`](https://github.com/audiojs/stretch)'s pvoc-lock/transient. MIT.
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@audio/spectral-pvoc",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Phase-vocoder engine — peak-gated and peak-locked spectral bin scatter (Bernsee, Laroche-Dolson), phase locking, peak finding; powers @audio/shift-pvoc* and @audio/stretch-pvoc*",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"main": "pvoc.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./pvoc.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"pvoc.js"
|
|
14
|
+
],
|
|
15
|
+
"dependencies": {},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"audio",
|
|
18
|
+
"dsp",
|
|
19
|
+
"phase-vocoder",
|
|
20
|
+
"pvoc",
|
|
21
|
+
"pitch-shift",
|
|
22
|
+
"time-stretch",
|
|
23
|
+
"spectral"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"author": "Dmitry Iv. <dfcreative@gmail.com>",
|
|
27
|
+
"engines": {
|
|
28
|
+
"node": ">=18"
|
|
29
|
+
},
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"access": "public"
|
|
32
|
+
},
|
|
33
|
+
"repository": {
|
|
34
|
+
"directory": "packages/spectral-pvoc"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/audiojs/spectral/tree/main/packages/spectral-pvoc"
|
|
37
|
+
}
|
package/pvoc.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
// Phase-vocoder engine — the spectral bin-scatter/phase-lock machinery behind
|
|
2
|
+
// pitch shifting (@audio/shift-pvoc*), time stretching (@audio/stretch-pvoc*), and
|
|
3
|
+
// spectral freezing. Operates on STFT magnitude/phase frames; pair with
|
|
4
|
+
// fourier-transform/stft (ana/syn hop) or @audio/stft (AMS).
|
|
5
|
+
//
|
|
6
|
+
// - scatterGated: Bernsee/SMB peak-gated bin scatter (pitch shift by ratio)
|
|
7
|
+
// - scatterLocked: Laroche-Dolson rigid-ROI peak-locked scatter (phase coherent)
|
|
8
|
+
// - lockPhase: lock non-peak bin phases to their nearest peak's rotation (time stretch)
|
|
9
|
+
// - findPeaks / nearestPeak / makeFrameRatio / wrapPhase: the supporting primitives
|
|
10
|
+
|
|
11
|
+
export const PI2 = Math.PI * 2
|
|
12
|
+
|
|
13
|
+
export function wrapPhase(p) {
|
|
14
|
+
return p - Math.round(p / PI2) * PI2
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// First-order local magnitude peaks above a fraction of the frame's peak.
|
|
18
|
+
// ±1 comparison keeps closely-spaced chord partials whose mainlobes overlap. `>=` on the
|
|
19
|
+
// left / `>` on the right reports the trailing edge of an exact-magnitude plateau exactly
|
|
20
|
+
// once, instead of a strict `>` on both sides missing the whole plateau.
|
|
21
|
+
export function findPeaks(mag, half) {
|
|
22
|
+
let maxM = 0
|
|
23
|
+
for (let k = 0; k <= half; k++) if (mag[k] > maxM) maxM = mag[k]
|
|
24
|
+
let floor = Math.max(1e-8, maxM * 0.005)
|
|
25
|
+
let peaks = []
|
|
26
|
+
for (let k = 1; k < half; k++) {
|
|
27
|
+
let v = mag[k]
|
|
28
|
+
if (v < floor) continue
|
|
29
|
+
if (v >= mag[k - 1] && v > mag[k + 1]) peaks.push(k)
|
|
30
|
+
}
|
|
31
|
+
return peaks
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Binary-search nearest peak index for bin k.
|
|
35
|
+
export function nearestPeak(peaks, k) {
|
|
36
|
+
if (!peaks.length) return -1
|
|
37
|
+
let lo = 0, hi = peaks.length - 1
|
|
38
|
+
while (lo < hi) {
|
|
39
|
+
let mid = (lo + hi) >> 1
|
|
40
|
+
if (peaks[mid] < k) lo = mid + 1
|
|
41
|
+
else hi = mid
|
|
42
|
+
}
|
|
43
|
+
if (lo > 0 && Math.abs(peaks[lo - 1] - k) <= Math.abs(peaks[lo] - k)) return lo - 1
|
|
44
|
+
return lo
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Peak-gated bin scatter (Bernsee/SMB scheme): every analysis bin at or adjacent (±1) to a
|
|
48
|
+
// local magnitude peak advances phase at its own instantaneous frequency, scales it by
|
|
49
|
+
// `ratio`, and deposits into the destination bin that frequency implies. Colliding bins
|
|
50
|
+
// accumulate in the energy domain (Σmag², √ at the end) — synthesis treats each bin as an
|
|
51
|
+
// independent oscillator, so energies add where magnitude-summing overshoots (+4.3 dB for
|
|
52
|
+
// a Hann mainlobe's own ±1 bins landing together) and last-writer-wins discards every
|
|
53
|
+
// other contributor. The frequency written to a bin is its loudest contributor's (a
|
|
54
|
+
// quieter contributor's frequency estimate is masked anyway).
|
|
55
|
+
// The gate keeps only mainlobe cores; skirt bins it drops carry real energy belonging to
|
|
56
|
+
// the same partials. The frame is renormalized so kept-bin energy matches the input
|
|
57
|
+
// frame's — minus content whose destination fell outside Nyquist, which is legitimately
|
|
58
|
+
// lost — times WIN_GAIN: concentrating a windowed mainlobe into one bin makes the ISTFT
|
|
59
|
+
// frame a pure sinusoid where the analysis frame was a windowed one, and through the
|
|
60
|
+
// engine's w·(·)/Σw² overlap-add that costs exactly mean(w)/rms(w). Per-frame and causal,
|
|
61
|
+
// so batch and stream reconstruct at identical loudness with no whole-signal correction.
|
|
62
|
+
// `newMag`/`newFreq`/`peakMag` are caller-owned scratch sized `half+1`, zero-filled by the
|
|
63
|
+
// caller before the call. `prevPhase` is the previous frame's unwrapped phase, or `null`
|
|
64
|
+
// on the first frame.
|
|
65
|
+
export function scatterGated(mag, phase, prevPhase, ratio, ctx, newMag, newFreq, peakMag) {
|
|
66
|
+
let { half, hop, freqPerBin } = ctx
|
|
67
|
+
let maxM = 0
|
|
68
|
+
for (let k = 0; k <= half; k++) if (mag[k] > maxM) maxM = mag[k]
|
|
69
|
+
let floor = Math.max(1e-8, maxM * 0.005)
|
|
70
|
+
let eIn = 0, eOut = 0
|
|
71
|
+
for (let k = 0; k <= half; k++) {
|
|
72
|
+
let e = mag[k] * mag[k]
|
|
73
|
+
eIn += e
|
|
74
|
+
let eligible = false
|
|
75
|
+
for (let d = -1; d <= 1; d++) {
|
|
76
|
+
let j = k + d
|
|
77
|
+
if (j <= 0 || j >= half) continue
|
|
78
|
+
if (mag[j] >= floor && mag[j] >= mag[j - 1] && mag[j] > mag[j + 1]) { eligible = true; break }
|
|
79
|
+
}
|
|
80
|
+
if (!eligible) continue
|
|
81
|
+
let trueFreq
|
|
82
|
+
if (!prevPhase) trueFreq = k * freqPerBin
|
|
83
|
+
else {
|
|
84
|
+
let dp = wrapPhase(phase[k] - prevPhase[k] - k * freqPerBin * hop)
|
|
85
|
+
trueFreq = k * freqPerBin + dp / hop
|
|
86
|
+
}
|
|
87
|
+
let shifted = trueFreq * ratio
|
|
88
|
+
let destBin = Math.round(shifted / freqPerBin)
|
|
89
|
+
if (destBin < 0 || destBin > half) { eIn -= e; continue }
|
|
90
|
+
eOut += e
|
|
91
|
+
let r = lobeGain(shifted - destBin * freqPerBin, ctx.N)
|
|
92
|
+
newMag[destBin] += e / (r * r)
|
|
93
|
+
if (mag[k] > peakMag[destBin]) { peakMag[destBin] = mag[k]; newFreq[destBin] = shifted }
|
|
94
|
+
}
|
|
95
|
+
let g = (eOut > 1e-24 && eIn > 1e-24 ? Math.sqrt(eIn / eOut) : 1) * WIN_GAIN
|
|
96
|
+
for (let k = 0; k <= half; k++) if (newMag[k]) newMag[k] = Math.sqrt(newMag[k]) * g
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// rms(w)/mean(w) for the engine's periodic Hann: sqrt(3/8)/(1/2) = sqrt(3/2).
|
|
100
|
+
export const WIN_GAIN = Math.sqrt(1.5)
|
|
101
|
+
|
|
102
|
+
// Mean overlap-add amplitude of a partial whose intra-frame (bin-grid) and inter-frame
|
|
103
|
+
// (true) frequencies differ by `dw` rad/sample: |W(dw)|/W(0) for the engine's periodic
|
|
104
|
+
// Hann of length N. The synthesized bin oscillates on the bin grid inside each frame
|
|
105
|
+
// while its phase steps at the true frequency across frames, so overlapping frames sum
|
|
106
|
+
// slightly incoherently — the classic vocoder scalloping loss, deterministic per bin.
|
|
107
|
+
function lobeGain(dw, N) {
|
|
108
|
+
if (!dw) return 1
|
|
109
|
+
let d = (t) => {
|
|
110
|
+
let s = Math.sin(t / 2)
|
|
111
|
+
return Math.abs(s) < 1e-12 ? N : Math.sin(N * t / 2) / s
|
|
112
|
+
}
|
|
113
|
+
let b = PI2 / N
|
|
114
|
+
return Math.abs(0.5 * d(dw) + 0.25 * d(dw - b) + 0.25 * d(dw + b)) / (0.5 * N)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Peak-locked rigid-ROI bin scatter (Laroche-Dolson): each `findPeaks` peak advances its own
|
|
118
|
+
// phase at its instantaneous frequency × `ratio`; every other bin rides along rigidly at its
|
|
119
|
+
// nearest peak's integer bin-shift, carrying phase relative to that peak (phase coherence
|
|
120
|
+
// across the peak's region of influence). Colliding destination bins accumulate in the
|
|
121
|
+
// energy domain (Σmag², √ at the end); the phase written is the loudest contributor's —
|
|
122
|
+
// same RMS-preserving collision policy as `scatterGated`.
|
|
123
|
+
// `reset` skips phase-derivative estimation (first frame, or a caller-detected phase
|
|
124
|
+
// discontinuity such as a transient) and uses the analysis phase directly instead of
|
|
125
|
+
// integrating. `syn` is the caller-owned running per-bin phase accumulator (persists across
|
|
126
|
+
// frames). `newMag`/`newPhase`/`peakMag` are caller-owned scratch sized `half+1`, zero-filled
|
|
127
|
+
// by the caller; `peakDest`/`peakSynPhase` are caller-owned scratch sized `peaks.length`.
|
|
128
|
+
export function scatterLocked(mag, phase, prevPhase, reset, peaks, ratio, ctx, syn, newMag, newPhase, peakDest, peakSynPhase, peakMag) {
|
|
129
|
+
let { half, hop, freqPerBin } = ctx
|
|
130
|
+
if (_boost.length <= half) _boost = new Float64Array(half + 1)
|
|
131
|
+
for (let i = 0; i < peaks.length; i++) {
|
|
132
|
+
let k = peaks[i]
|
|
133
|
+
let trueFreq
|
|
134
|
+
if (reset) trueFreq = k * freqPerBin
|
|
135
|
+
else {
|
|
136
|
+
let dp = wrapPhase(phase[k] - prevPhase[k] - k * freqPerBin * hop)
|
|
137
|
+
trueFreq = k * freqPerBin + dp / hop
|
|
138
|
+
}
|
|
139
|
+
let shifted = trueFreq * ratio
|
|
140
|
+
// Shift the lobe by the integer bin count closest to the true frequency delta: the
|
|
141
|
+
// lobe's own frac is preserved, so the intra-/inter-frame frequency mismatch stays
|
|
142
|
+
// within ±half a bin — where the scalloping model below is accurate.
|
|
143
|
+
let destBin = k + Math.round((shifted - trueFreq) / freqPerBin)
|
|
144
|
+
if (destBin < 0 || destBin > half) { peakDest[i] = -1; continue }
|
|
145
|
+
let newSyn = reset ? phase[k] : wrapPhase(syn[destBin] + shifted * hop)
|
|
146
|
+
peakDest[i] = destBin
|
|
147
|
+
peakSynPhase[i] = newSyn
|
|
148
|
+
syn[destBin] = newSyn
|
|
149
|
+
let r = lobeGain(shifted - (trueFreq + (destBin - k) * freqPerBin), ctx.N)
|
|
150
|
+
_boost[i] = 1 / (r * r)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// No WIN_GAIN here: the rigid ROI shift carries the whole mainlobe shape to the
|
|
154
|
+
// destination, so the ISTFT frame stays a windowed one — only collision and
|
|
155
|
+
// past-Nyquist bookkeeping need correction. Bins riding a peak whose destination
|
|
156
|
+
// fell outside Nyquist are legitimately lost and excluded from the energy target.
|
|
157
|
+
let eIn = 0, eOut = 0
|
|
158
|
+
for (let k = 0; k <= half; k++) {
|
|
159
|
+
let pi = nearestPeak(peaks, k)
|
|
160
|
+
if (pi < 0) continue
|
|
161
|
+
let destBin = peakDest[pi]
|
|
162
|
+
if (destBin < 0) continue
|
|
163
|
+
let e = mag[k] * mag[k]
|
|
164
|
+
eIn += e
|
|
165
|
+
let pk = peaks[pi]
|
|
166
|
+
let dest = destBin + (k - pk)
|
|
167
|
+
if (dest < 0 || dest > half) continue
|
|
168
|
+
eOut += e
|
|
169
|
+
newMag[dest] += e * _boost[pi]
|
|
170
|
+
if (mag[k] > peakMag[dest]) { peakMag[dest] = mag[k]; newPhase[dest] = peakSynPhase[pi] + (phase[k] - phase[pk]) }
|
|
171
|
+
}
|
|
172
|
+
let g = eOut > 1e-24 && eIn > 1e-24 ? Math.sqrt(eIn / eOut) : 1
|
|
173
|
+
for (let k = 0; k <= half; k++) if (newMag[k]) newMag[k] = Math.sqrt(newMag[k]) * g
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// scatterLocked-internal per-peak scalloping boosts; grown once, reused across frames.
|
|
177
|
+
let _boost = new Float64Array(0)
|
|
178
|
+
|
|
179
|
+
// Variable-ratio resolver for STFT process callbacks. Returns `{ scalar, at }`
|
|
180
|
+
// where `scalar` is the ratio at t=0 and `at(frameStart, sampleRate)` resolves
|
|
181
|
+
// the ratio for a given frame position.
|
|
182
|
+
export function makeFrameRatio(ratio) {
|
|
183
|
+
if (typeof ratio !== 'function') return { scalar: ratio, at: () => ratio }
|
|
184
|
+
let scalar = ratio(0)
|
|
185
|
+
return {
|
|
186
|
+
scalar,
|
|
187
|
+
at(frameStart, sampleRate) {
|
|
188
|
+
let r = ratio(Math.max(0, frameStart) / sampleRate)
|
|
189
|
+
return (!Number.isFinite(r) || r <= 0) ? (scalar || 1) : r
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Time-stretch phase locking (Laroche & Dolson 1999) ──
|
|
195
|
+
// Prominence-based peak mask tuned for stretch: keeps chord partials, rejects
|
|
196
|
+
// leakage shoulders. Internal to lockPhase; distinct from the shift-side findPeaks.
|
|
197
|
+
let _peakMask = new Uint8Array(0)
|
|
198
|
+
function peakMask(mag, half) {
|
|
199
|
+
if (_peakMask.length !== half + 1) _peakMask = new Uint8Array(half + 1)
|
|
200
|
+
let peaks = _peakMask
|
|
201
|
+
peaks.fill(0)
|
|
202
|
+
if (half <= 1) {
|
|
203
|
+
peaks[0] = 1
|
|
204
|
+
if (half === 1) peaks[1] = 1
|
|
205
|
+
return peaks
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let maxMag = 0
|
|
209
|
+
for (let k = 0; k <= half; k++) if (mag[k] > maxMag) maxMag = mag[k]
|
|
210
|
+
|
|
211
|
+
let minMag = Math.max(1e-8, maxMag * 0.015)
|
|
212
|
+
let minProm = Math.max(1e-9, maxMag * 0.003)
|
|
213
|
+
let lastPeak = -2, lastPeakMag = 0
|
|
214
|
+
|
|
215
|
+
for (let k = 1; k < half; k++) {
|
|
216
|
+
let value = mag[k]
|
|
217
|
+
if (value < minMag || value < mag[k - 1] || value < mag[k + 1]) continue
|
|
218
|
+
|
|
219
|
+
let shoulder = Math.max(mag[k - 1], mag[k + 1], k > 1 ? mag[k - 2] : 0, k + 2 <= half ? mag[k + 2] : 0)
|
|
220
|
+
if (value - shoulder < minProm && value < maxMag * 0.1) continue
|
|
221
|
+
|
|
222
|
+
if (k - lastPeak <= 1) {
|
|
223
|
+
if (value > lastPeakMag) { peaks[lastPeak] = 0; peaks[k] = 1; lastPeak = k; lastPeakMag = value }
|
|
224
|
+
continue
|
|
225
|
+
}
|
|
226
|
+
peaks[k] = 1; lastPeak = k; lastPeakMag = value
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
let found = false
|
|
230
|
+
for (let k = 0; k <= half; k++) if (peaks[k]) { found = true; break }
|
|
231
|
+
if (!found) {
|
|
232
|
+
let best = 0
|
|
233
|
+
for (let k = 1; k <= half; k++) if (mag[k] > mag[best]) best = k
|
|
234
|
+
peaks[best] = 1
|
|
235
|
+
}
|
|
236
|
+
return peaks
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Lock non-peak bin phases to nearest peak's rotation, in place on `propPhase`.
|
|
240
|
+
let _peakBins = new Int32Array(0)
|
|
241
|
+
export function lockPhase(phase, propPhase, mag, half) {
|
|
242
|
+
let peaks = peakMask(mag, half)
|
|
243
|
+
if (_peakBins.length < half + 1) _peakBins = new Int32Array(half + 1)
|
|
244
|
+
let peakBins = _peakBins, nBins = 0
|
|
245
|
+
for (let k = 0; k <= half; k++) if (peaks[k]) peakBins[nBins++] = k
|
|
246
|
+
if (!nBins) return
|
|
247
|
+
|
|
248
|
+
for (let i = 0; i < nBins; i++) {
|
|
249
|
+
let pk = peakBins[i]
|
|
250
|
+
let start = i === 0 ? 0 : Math.floor((peakBins[i - 1] + pk) * 0.5) + 1
|
|
251
|
+
let end = i === nBins - 1 ? half : Math.floor((pk + peakBins[i + 1]) * 0.5)
|
|
252
|
+
let delta = propPhase[pk] - phase[pk]
|
|
253
|
+
let lockFloor = Math.max(1e-10, mag[pk] * 0.03)
|
|
254
|
+
for (let k = start; k <= end; k++) {
|
|
255
|
+
if (k === pk || mag[k] < lockFloor) continue
|
|
256
|
+
propPhase[k] = phase[k] + delta
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|