@audio/spectral-pvoc 1.0.1 → 1.1.1

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 (4) hide show
  1. package/README.md +2 -0
  2. package/index.d.ts +31 -1
  3. package/package.json +5 -2
  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 CHANGED
@@ -43,7 +43,7 @@ export function scatterGated(
43
43
 
44
44
  /**
45
45
  * Laroche-Dolson rigid-ROI peak-locked bin scatter — phase-coherent shift by `ratio`.
46
- * `syn` is the caller-owned running per-bin phase accumulator (persists across frames), mutated in place.
46
+ * `syn` is the caller-owned running frame-center phase accumulator indexed by source bin (NaN means untracked), mutated in place.
47
47
  * `newMag`/`newPhase`/`peakMag` are caller-owned scratch sized `half+1`; `peakDest`/`peakSynPhase` sized `peaks.length`. All zero-filled before the call.
48
48
  */
49
49
  export function scatterLocked(
@@ -77,3 +77,33 @@ export function lockPhase(
77
77
  mag: Float32Array | Float64Array,
78
78
  half: number,
79
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,12 +1,15 @@
1
1
  {
2
2
  "name": "@audio/spectral-pvoc",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
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
- ".": "./pvoc.js",
9
+ ".": {
10
+ "types": "./index.d.ts",
11
+ "default": "./pvoc.js"
12
+ },
10
13
  "./package.json": "./package.json"
11
14
  },
12
15
  "files": [
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.round(p / PI2) * PI2
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` 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
+ // 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
- let newSyn = reset ? phase[k] : wrapPhase(syn[destBin] + shifted * hop)
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
- // Lock non-peak bin phases to nearest peak's rotation, in place on `propPhase`.
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 lockPhase(phase, propPhase, mag, half) {
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
- if (!nBins) return
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
- if (k === pk || mag[k] < lockFloor) continue
256
- propPhase[k] = phase[k] + delta
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
  }