@audio/spectral-pvoc 1.0.0 → 1.1.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 +2 -0
- package/index.d.ts +109 -0
- package/package.json +10 -4
- package/pvoc.js +86 -16
package/README.md
CHANGED
|
@@ -13,6 +13,8 @@ import { findPeaks, scatterLocked, lockPhase, makeFrameRatio } from '@audio/spec
|
|
|
13
13
|
| `scatterGated(mag, phase, prevPhase, ratio, ctx, …scratch)` | Bernsee/SMB peak-gated bin scatter — pitch shift by ratio, RMS-preserving collisions |
|
|
14
14
|
| `scatterLocked(mag, phase, prevPhase, reset, peaks, ratio, ctx, …scratch)` | Laroche-Dolson rigid-ROI scatter — phase-coherent shift |
|
|
15
15
|
| `lockPhase(phase, propPhase, mag, half)` | lock non-peak bins to their peak's rotation (time-stretch coherence) |
|
|
16
|
+
| `lockMap(mag, half, owner)` | which peak each bin locks to (−1: free) — the regions `lockPhase` uses |
|
|
17
|
+
| `lockState(half)` / `lockAdvance(re, im, st, reset, anaHop, synHop, freqPerBin, half)` | the phase-locked vocoder frame on complex bins: locked bins rotate by one complex multiply, only free bins take trig |
|
|
16
18
|
| `findPeaks(mag, half)` / `nearestPeak(peaks, k)` | local-maxima peak indices + nearest lookup |
|
|
17
19
|
| `makeFrameRatio(ratio)` | scalar or time-varying ratio → per-frame resolver |
|
|
18
20
|
| `wrapPhase(p)` / `WIN_GAIN` / `PI2` | phase wrap, Hann OLA gain constant |
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Phase-vocoder engine — frame-level primitives over STFT magnitude/phase arrays. Pure math, no defaults. */
|
|
2
|
+
|
|
3
|
+
/** Per-frame geometry the scatter/lock primitives read from. */
|
|
4
|
+
export interface PvocCtx {
|
|
5
|
+
/** Nyquist bin index (frameSize / 2) */
|
|
6
|
+
half: number
|
|
7
|
+
/** hop size, samples */
|
|
8
|
+
hop: number
|
|
9
|
+
/** Hz per FFT bin (fs / N) */
|
|
10
|
+
freqPerBin: number
|
|
11
|
+
/** analysis window / FFT size */
|
|
12
|
+
N: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const PI2: number
|
|
16
|
+
/** rms(w)/mean(w) for the engine's periodic Hann — energy-preserving OLA gain for scatterGated. */
|
|
17
|
+
export const WIN_GAIN: number
|
|
18
|
+
|
|
19
|
+
/** Wrap a phase (radians) into (−π, π]. */
|
|
20
|
+
export function wrapPhase(p: number): number
|
|
21
|
+
|
|
22
|
+
/** Local magnitude maxima of `mag[0..half]` above a fraction of the frame's peak. Returns bin indices, ascending. */
|
|
23
|
+
export function findPeaks(mag: Float32Array | Float64Array, half: number): number[]
|
|
24
|
+
|
|
25
|
+
/** Binary-search the peak (from `findPeaks`) nearest bin `k`. Returns −1 if `peaks` is empty. */
|
|
26
|
+
export function nearestPeak(peaks: number[], k: number): number
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Bernsee/SMB peak-gated bin scatter — pitch shift by `ratio`, RMS-preserving collisions.
|
|
30
|
+
* `newMag`/`newFreq`/`peakMag` are caller-owned scratch sized `half+1`, zero-filled before the call.
|
|
31
|
+
* Writes into `newMag`/`newFreq` in place.
|
|
32
|
+
*/
|
|
33
|
+
export function scatterGated(
|
|
34
|
+
mag: Float32Array | Float64Array,
|
|
35
|
+
phase: Float32Array | Float64Array,
|
|
36
|
+
prevPhase: Float32Array | Float64Array | null,
|
|
37
|
+
ratio: number,
|
|
38
|
+
ctx: PvocCtx,
|
|
39
|
+
newMag: Float64Array,
|
|
40
|
+
newFreq: Float64Array,
|
|
41
|
+
peakMag: Float64Array,
|
|
42
|
+
): void
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Laroche-Dolson rigid-ROI peak-locked bin scatter — phase-coherent shift by `ratio`.
|
|
46
|
+
* `syn` is the caller-owned running frame-center phase accumulator indexed by source bin (NaN means untracked), mutated in place.
|
|
47
|
+
* `newMag`/`newPhase`/`peakMag` are caller-owned scratch sized `half+1`; `peakDest`/`peakSynPhase` sized `peaks.length`. All zero-filled before the call.
|
|
48
|
+
*/
|
|
49
|
+
export function scatterLocked(
|
|
50
|
+
mag: Float32Array | Float64Array,
|
|
51
|
+
phase: Float32Array | Float64Array,
|
|
52
|
+
prevPhase: Float32Array | Float64Array | null,
|
|
53
|
+
reset: boolean,
|
|
54
|
+
peaks: number[],
|
|
55
|
+
ratio: number,
|
|
56
|
+
ctx: PvocCtx,
|
|
57
|
+
syn: Float64Array,
|
|
58
|
+
newMag: Float64Array,
|
|
59
|
+
newPhase: Float64Array,
|
|
60
|
+
peakDest: Int32Array,
|
|
61
|
+
peakSynPhase: Float64Array,
|
|
62
|
+
peakMag: Float64Array,
|
|
63
|
+
): void
|
|
64
|
+
|
|
65
|
+
/** Scalar or time-varying ratio → per-frame resolver. */
|
|
66
|
+
export function makeFrameRatio(ratio: number | ((t: number) => number)): {
|
|
67
|
+
/** ratio at t = 0 */
|
|
68
|
+
scalar: number
|
|
69
|
+
/** ratio at a given frame position */
|
|
70
|
+
at(frameStart: number, sampleRate: number): number
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Lock non-peak bin phases to their nearest peak's rotation, in place on `propPhase` (time-stretch coherence). */
|
|
74
|
+
export function lockPhase(
|
|
75
|
+
phase: Float32Array | Float64Array,
|
|
76
|
+
propPhase: Float32Array | Float64Array,
|
|
77
|
+
mag: Float32Array | Float64Array,
|
|
78
|
+
half: number,
|
|
79
|
+
): void
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Which peak each bin locks to, into `owner` (sized ≥ `half+1`): the peak whose rotation bin k takes, or −1 for a
|
|
83
|
+
* free bin (a peak itself, or under 3% of its peak's magnitude). The regions `lockPhase` uses; returns `owner`.
|
|
84
|
+
*/
|
|
85
|
+
export function lockMap(mag: Float32Array | Float64Array, half: number, owner: Int32Array): Int32Array
|
|
86
|
+
|
|
87
|
+
/** Complex-domain locked-vocoder state over `half+1` bins; the caller fills `mag` with each frame's magnitudes. */
|
|
88
|
+
export interface LockState {
|
|
89
|
+
mag: Float64Array
|
|
90
|
+
owner: Int32Array
|
|
91
|
+
xr: Float64Array
|
|
92
|
+
xi: Float64Array
|
|
93
|
+
xm: Float64Array
|
|
94
|
+
ur: Float64Array
|
|
95
|
+
ui: Float64Array
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** State for `lockAdvance` over `half+1` bins. */
|
|
99
|
+
export function lockState(half: number): LockState
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* One phase-locked vocoder frame on complex bins, in place: `re`/`im` hold the analysis bins, then the synthesis bins
|
|
103
|
+
* (`st.mag` = this frame's magnitudes). `reset` restarts synthesis phases at the analysis phases (first frame,
|
|
104
|
+
* transient); otherwise free bins advance at their instantaneous frequency and locked bins take their peak's rotation.
|
|
105
|
+
*/
|
|
106
|
+
export function lockAdvance(
|
|
107
|
+
re: Float64Array, im: Float64Array, st: LockState, reset: boolean,
|
|
108
|
+
anaHop: number, synHop: number, freqPerBin: number, half: number,
|
|
109
|
+
): void
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@audio/spectral-pvoc",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
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
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"main": "pvoc.js",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"default": "./pvoc.js"
|
|
12
|
+
},
|
|
10
13
|
"./package.json": "./package.json"
|
|
11
14
|
},
|
|
12
15
|
"files": [
|
|
13
|
-
"pvoc.js"
|
|
16
|
+
"pvoc.js",
|
|
17
|
+
"index.d.ts"
|
|
14
18
|
],
|
|
15
19
|
"dependencies": {},
|
|
16
20
|
"keywords": [
|
|
@@ -30,8 +34,10 @@
|
|
|
30
34
|
"publishConfig": {
|
|
31
35
|
"access": "public"
|
|
32
36
|
},
|
|
37
|
+
"types": "index.d.ts",
|
|
33
38
|
"repository": {
|
|
34
39
|
"directory": "packages/spectral-pvoc"
|
|
35
40
|
},
|
|
36
|
-
"homepage": "https://github.com/audiojs/spectral/tree/main/packages/spectral-pvoc"
|
|
41
|
+
"homepage": "https://github.com/audiojs/spectral/tree/main/packages/spectral-pvoc",
|
|
42
|
+
"funding": "https://github.com/sponsors/audiojs"
|
|
37
43
|
}
|
package/pvoc.js
CHANGED
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
// - scatterGated: Bernsee/SMB peak-gated bin scatter (pitch shift by ratio)
|
|
7
7
|
// - scatterLocked: Laroche-Dolson rigid-ROI peak-locked scatter (phase coherent)
|
|
8
8
|
// - lockPhase: lock non-peak bin phases to their nearest peak's rotation (time stretch)
|
|
9
|
+
// - lockMap: which peak each bin locks to — the same regions, for complex-domain locking
|
|
10
|
+
// - lockState / lockAdvance: the phase-locked vocoder step on complex bins (no polar round trip)
|
|
9
11
|
// - findPeaks / nearestPeak / makeFrameRatio / wrapPhase: the supporting primitives
|
|
10
12
|
|
|
11
13
|
export const PI2 = Math.PI * 2
|
|
12
14
|
|
|
13
15
|
export function wrapPhase(p) {
|
|
14
|
-
return p - Math.
|
|
16
|
+
return p - Math.floor(p / PI2 + 0.5) * PI2 // floor(x + 0.5) rounds like Math.round here, several times faster in V8 (hot: per bin per frame)
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
// First-order local magnitude peaks above a fraction of the frame's peak.
|
|
@@ -122,11 +124,18 @@ function lobeGain(dw, N) {
|
|
|
122
124
|
// same RMS-preserving collision policy as `scatterGated`.
|
|
123
125
|
// `reset` skips phase-derivative estimation (first frame, or a caller-detected phase
|
|
124
126
|
// discontinuity such as a transient) and uses the analysis phase directly instead of
|
|
125
|
-
// integrating. `syn`
|
|
126
|
-
//
|
|
127
|
+
// integrating. `syn` holds frame-center phases indexed by SOURCE bin, including each
|
|
128
|
+
// peak's neighbours so moving peaks inherit coherent history. NaN marks untracked bins.
|
|
129
|
+
// `newMag`/`newPhase`/`peakMag` are caller-owned scratch sized `half+1`, zero-filled
|
|
127
130
|
// by the caller; `peakDest`/`peakSynPhase` are caller-owned scratch sized `peaks.length`.
|
|
128
131
|
export function scatterLocked(mag, phase, prevPhase, reset, peaks, ratio, ctx, syn, newMag, newPhase, peakDest, peakSynPhase, peakMag) {
|
|
129
132
|
let { half, hop, freqPerBin } = ctx
|
|
133
|
+
// Without a resolved partial there is no pitch to move. Preserve DC and
|
|
134
|
+
// short boundary frames rather than dropping their energy; re-seed later peaks.
|
|
135
|
+
if (!peaks.length) {
|
|
136
|
+
for (let k = 0; k <= half; k++) { newMag[k] = mag[k]; newPhase[k] = phase[k]; syn[k] = NaN }
|
|
137
|
+
return
|
|
138
|
+
}
|
|
130
139
|
if (_boost.length <= half) _boost = new Float64Array(half + 1)
|
|
131
140
|
for (let i = 0; i < peaks.length; i++) {
|
|
132
141
|
let k = peaks[i]
|
|
@@ -142,10 +151,11 @@ export function scatterLocked(mag, phase, prevPhase, reset, peaks, ratio, ctx, s
|
|
|
142
151
|
// within ±half a bin — where the scalloping model below is accurate.
|
|
143
152
|
let destBin = k + Math.round((shifted - trueFreq) / freqPerBin)
|
|
144
153
|
if (destBin < 0 || destBin > half) { peakDest[i] = -1; continue }
|
|
145
|
-
|
|
154
|
+
// Center-referenced phase survives destination-bin changes (each bin adds π
|
|
155
|
+
// at N/2). Read all previous phases before updating source-bin history below.
|
|
156
|
+
let newSyn = reset || !Number.isFinite(syn[k]) ? phase[k] + k * Math.PI : wrapPhase(syn[k] + shifted * hop)
|
|
146
157
|
peakDest[i] = destBin
|
|
147
|
-
peakSynPhase[i] = newSyn
|
|
148
|
-
syn[destBin] = newSyn
|
|
158
|
+
peakSynPhase[i] = newSyn - destBin * Math.PI
|
|
149
159
|
let r = lobeGain(shifted - (trueFreq + (destBin - k) * freqPerBin), ctx.N)
|
|
150
160
|
_boost[i] = 1 / (r * r)
|
|
151
161
|
}
|
|
@@ -157,12 +167,13 @@ export function scatterLocked(mag, phase, prevPhase, reset, peaks, ratio, ctx, s
|
|
|
157
167
|
let eIn = 0, eOut = 0
|
|
158
168
|
for (let k = 0; k <= half; k++) {
|
|
159
169
|
let pi = nearestPeak(peaks, k)
|
|
160
|
-
if (pi < 0) continue
|
|
170
|
+
if (pi < 0) { syn[k] = NaN; continue }
|
|
161
171
|
let destBin = peakDest[pi]
|
|
162
|
-
if (destBin < 0) continue
|
|
172
|
+
if (destBin < 0) { syn[k] = NaN; continue }
|
|
163
173
|
let e = mag[k] * mag[k]
|
|
164
174
|
eIn += e
|
|
165
175
|
let pk = peaks[pi]
|
|
176
|
+
syn[k] = wrapPhase(peakSynPhase[pi] + destBin * Math.PI + (phase[k] - phase[pk]) + (k - pk) * Math.PI)
|
|
166
177
|
let dest = destBin + (k - pk)
|
|
167
178
|
if (dest < 0 || dest > half) continue
|
|
168
179
|
eOut += e
|
|
@@ -236,24 +247,83 @@ function peakMask(mag, half) {
|
|
|
236
247
|
return peaks
|
|
237
248
|
}
|
|
238
249
|
|
|
239
|
-
//
|
|
250
|
+
// Which bins ride which peak: `owner[k]` = the peak whose rotation bin k takes (its region of
|
|
251
|
+
// influence reaches halfway to the neighbouring peaks), or -1 for a free bin — a peak itself, or a
|
|
252
|
+
// bin under 3% of its peak's magnitude, which keeps its own phase advance. Returns `owner`.
|
|
240
253
|
let _peakBins = new Int32Array(0)
|
|
241
|
-
export function
|
|
254
|
+
export function lockMap(mag, half, owner) {
|
|
242
255
|
let peaks = peakMask(mag, half)
|
|
243
256
|
if (_peakBins.length < half + 1) _peakBins = new Int32Array(half + 1)
|
|
244
257
|
let peakBins = _peakBins, nBins = 0
|
|
245
258
|
for (let k = 0; k <= half; k++) if (peaks[k]) peakBins[nBins++] = k
|
|
246
|
-
|
|
247
|
-
|
|
259
|
+
owner.fill(-1, 0, half + 1)
|
|
248
260
|
for (let i = 0; i < nBins; i++) {
|
|
249
261
|
let pk = peakBins[i]
|
|
250
262
|
let start = i === 0 ? 0 : Math.floor((peakBins[i - 1] + pk) * 0.5) + 1
|
|
251
263
|
let end = i === nBins - 1 ? half : Math.floor((pk + peakBins[i + 1]) * 0.5)
|
|
252
|
-
let delta = propPhase[pk] - phase[pk]
|
|
253
264
|
let lockFloor = Math.max(1e-10, mag[pk] * 0.03)
|
|
254
|
-
for (let k = start; k <= end; k++)
|
|
255
|
-
|
|
256
|
-
|
|
265
|
+
for (let k = start; k <= end; k++) if (k !== pk && !(mag[k] < lockFloor)) owner[k] = pk
|
|
266
|
+
}
|
|
267
|
+
return owner
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Lock non-peak bin phases to nearest peak's rotation, in place on `propPhase`.
|
|
271
|
+
let _owner = new Int32Array(0)
|
|
272
|
+
export function lockPhase(phase, propPhase, mag, half) {
|
|
273
|
+
if (_owner.length < half + 1) _owner = new Int32Array(half + 1)
|
|
274
|
+
let owner = lockMap(mag, half, _owner)
|
|
275
|
+
for (let k = 0; k <= half; k++) {
|
|
276
|
+
let pk = owner[k]
|
|
277
|
+
if (pk >= 0) propPhase[k] = phase[k] + (propPhase[pk] - phase[pk])
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ── Complex-domain phase locking ──
|
|
282
|
+
// The propagate-then-lockPhase vocoder step on complex bins (pair with fourier-transform/stft's `complex` frames).
|
|
283
|
+
// A bin riding a peak takes the peak's rotation R = Y(p)·conj(X(p))/|X(p)|², Y(k) = X(k)·R: one complex multiply,
|
|
284
|
+
// the identity locking of Laroche & Dolson. Only free bins — the peaks, and bins too quiet to lock — advance at
|
|
285
|
+
// their instantaneous frequency, one atan2 and one sin/cos each. State keeps unit synthesis phasors and the previous
|
|
286
|
+
// analysis bins, so no bin needs its phase as an angle.
|
|
287
|
+
|
|
288
|
+
/** Frame-to-frame state over half+1 bins: `mag` (the caller fills this frame's magnitudes), the lock map, the
|
|
289
|
+
* previous analysis bins and magnitudes, the unit synthesis phasors. */
|
|
290
|
+
export function lockState(half) {
|
|
291
|
+
let n = half + 1, f = () => new Float64Array(n)
|
|
292
|
+
return { mag: f(), owner: new Int32Array(n), xr: f(), xi: f(), xm: f(), ur: f(), ui: f() }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** One vocoder frame in place: `re`/`im` hold the analysis bins, then the synthesis bins. `reset` (first frame, a
|
|
296
|
+
* transient) restarts each bin's synthesis phase at its analysis phase; otherwise free bins advance by their
|
|
297
|
+
* instantaneous frequency over the hops and locked bins take their peak's rotation. */
|
|
298
|
+
export function lockAdvance(re, im, st, reset, anaHop, synHop, freqPerBin, half) {
|
|
299
|
+
let { mag, owner, xr, xi, xm, ur, ui } = st, n = half + 1
|
|
300
|
+
if (reset) for (let k = 0; k < n; k++) {
|
|
301
|
+
let m = mag[k]
|
|
302
|
+
if (m) { ur[k] = re[k] / m; ui[k] = im[k] / m; continue }
|
|
303
|
+
let a = Math.atan2(im[k], re[k]) // a zero bin: the direction its zeros' signs give atan2
|
|
304
|
+
ur[k] = Math.cos(a); ui[k] = Math.sin(a)
|
|
305
|
+
} else {
|
|
306
|
+
lockMap(mag, half, owner)
|
|
307
|
+
for (let k = 0; k < n; k++) {
|
|
308
|
+
if (owner[k] >= 0) continue
|
|
309
|
+
// phase advance since the previous frame, arg(X·conj(Xprev)); a zero bin has no product angle: its own angles
|
|
310
|
+
let d = mag[k] && xm[k] ? Math.atan2(im[k] * xr[k] - re[k] * xi[k], re[k] * xr[k] + im[k] * xi[k]) : Math.atan2(im[k], re[k]) - Math.atan2(xi[k], xr[k])
|
|
311
|
+
let dp = wrapPhase(d - k * freqPerBin * anaHop)
|
|
312
|
+
let a = (k * freqPerBin + dp / anaHop) * synHop, c = Math.cos(a), s = Math.sin(a), u = ur[k], v = ui[k]
|
|
313
|
+
ur[k] = u * c - v * s
|
|
314
|
+
ui[k] = u * s + v * c
|
|
257
315
|
}
|
|
316
|
+
for (let k = 0, p = -1, rr = 0, ri = 0; k < n; k++) {
|
|
317
|
+
let pk = owner[k]
|
|
318
|
+
if (pk < 0) continue
|
|
319
|
+
if (pk !== p) { p = pk; let m = mag[p]; rr = (ur[p] * re[p] + ui[p] * im[p]) / m; ri = (ui[p] * re[p] - ur[p] * im[p]) / m }
|
|
320
|
+
let q = 1 / mag[k]
|
|
321
|
+
ur[k] = (re[k] * rr - im[k] * ri) * q
|
|
322
|
+
ui[k] = (re[k] * ri + im[k] * rr) * q
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
for (let k = 0; k < n; k++) {
|
|
326
|
+
xr[k] = re[k]; xi[k] = im[k]; xm[k] = mag[k]
|
|
327
|
+
re[k] = mag[k] * ur[k]; im[k] = mag[k] * ui[k]
|
|
258
328
|
}
|
|
259
329
|
}
|