@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.
Files changed (3) hide show
  1. package/README.md +20 -0
  2. package/package.json +37 -0
  3. 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
+ }