@audio/shift-formant 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 (2) hide show
  1. package/index.js +120 -0
  2. package/package.json +42 -0
package/index.js ADDED
@@ -0,0 +1,120 @@
1
+ import { fft, ifft } from 'fourier-transform'
2
+ import { makeStftShift } from '@audio/shift-core/stft'
3
+ import { findPeaks, makeFrameRatio, scatterLocked } from '@audio/shift-core'
4
+
5
+ // Formant-preserving pitch shift. The spectral envelope is extracted via cepstral liftering
6
+ // (low-quefrency coefficients) from the original frame. A peak-locked phase vocoder then
7
+ // shifts pitch (reusing the phase-lock architecture so partials stay coherent). Finally the
8
+ // shifted magnitude is divided by its own envelope and multiplied by the original envelope,
9
+ // re-imposing vowel timbre on the shifted pitch.
10
+
11
+ // The envelope/excitation quefrency boundary sits at sampleRate/width Hz — F0 above that
12
+ // leaks into the "envelope". F_BOUNDARY is the highest F0 the default lifter tolerates;
13
+ // because width scales with sampleRate, the safety margin (F0/F_BOUNDARY) is sampleRate-
14
+ // invariant, not just correct at 44.1kHz. Value chosen so the 44.1kHz/N=2048 default
15
+ // reproduces the old FFT-size-only cutoff (N>>6 = 32) exactly: 44100/32 = 1378.125.
16
+ const F_BOUNDARY = 1378.125
17
+
18
+ // Cepstral envelope extraction: liftering the log-magnitude spectrum's low quefrencies
19
+ // isolates the smooth vocal-tract response from the fast pitch-periodic excitation. `logMag`
20
+ // is already log-magnitude (EMA-smoothed, see process()). `zeroIm`/`lifted`/`env` are
21
+ // caller-owned scratch: `zeroIm` is permanently zero (a real-valued log-spectrum has no
22
+ // imaginary part) and never written here; `lifted`'s high-quefrency region stays zero across
23
+ // frames since `cutoff` is frame-invariant, so only the kept low-quefrency indices need
24
+ // overwriting; `env` is fully overwritten every call. Writes the envelope into `env`.
25
+ function cepstralEnvelope(logMag, N, cutoff, zeroIm, lifted, env) {
26
+ let half = N >> 1
27
+ let cep = ifft(logMag, zeroIm)
28
+ lifted[0] = cep[0]
29
+ let c = Math.min(cutoff, half - 1)
30
+ for (let n = 1; n < c; n++) {
31
+ lifted[n] = cep[n]
32
+ lifted[N - n] = cep[N - n]
33
+ }
34
+ let [envLogRe] = fft(lifted)
35
+ for (let k = 0; k <= half; k++) env[k] = Math.exp(envLogRe[k])
36
+ }
37
+
38
+ function process(mag, phase, state, ctx) {
39
+ if (!state.fr) state.fr = makeFrameRatio(ctx.ratioFn || ctx.ratio || 1)
40
+ let { N, half } = ctx
41
+ let ratio = state.fr.at(ctx.frameStart, ctx.sampleRate)
42
+ if (!state.prev) {
43
+ state.prev = new Float64Array(half + 1)
44
+ state.syn = new Float64Array(half + 1)
45
+ state.logMagAvg = new Float64Array(half + 1)
46
+ state.newMag = new Float64Array(half + 1)
47
+ state.newPhase = new Float64Array(half + 1)
48
+ state.peakMag = new Float64Array(half + 1)
49
+ state.peakDest = new Int32Array(half)
50
+ state.peakSynPhase = new Float64Array(half)
51
+ state.zeroIm = new Float64Array(half + 1)
52
+ state.lifted = new Float64Array(N)
53
+ state.env = new Float64Array(half + 1)
54
+ state.envelopeWidth = ctx.opts.envelopeWidth ?? Math.min(N >> 2, Math.max(8, Math.round(ctx.sampleRate / F_BOUNDARY)))
55
+ state.first = true
56
+ }
57
+ let { prev, syn, logMagAvg, newMag, newPhase, peakMag, peakDest, peakSynPhase, zeroIm, lifted, env, envelopeWidth } = state
58
+ newMag.fill(0)
59
+ newPhase.fill(0)
60
+ peakMag.fill(0)
61
+
62
+ // 1. Original spectral envelope extracted from a smoothed log-magnitude.
63
+ // Computing the envelope per-frame directly causes inter-partial bins to fluctuate at
64
+ // the chord beat frequency (e.g. 55 Hz for a 220/275 Hz pair). That 55 Hz beat aliases
65
+ // against the 86 Hz frame rate into ~31 Hz flutter on the correction factor — audible
66
+ // as a soft click on raised chord material. An EMA of log(mag) with α=0.6 (τ ≈ 22.7 ms
67
+ // at hop=512 / 44.1 kHz) stabilises the envelope: it converges within 3τ ≈ 68 ms
68
+ // (before the 20%-skip activeRegion window opens) and attenuates the 55 Hz oscillation
69
+ // by ≈3.65×, bringing it below the flicker perception threshold.
70
+ let alpha = 0.6
71
+ for (let k = 0; k <= half; k++) {
72
+ let lm = Math.log(Math.max(1e-8, mag[k]))
73
+ logMagAvg[k] = state.first ? lm : alpha * logMagAvg[k] + (1 - alpha) * lm
74
+ }
75
+ cepstralEnvelope(logMagAvg, N, envelopeWidth, zeroIm, lifted, env)
76
+
77
+ // 2. Peak-locked phase vocoder shift (shared core scatter — see shift-pvoc-lock for the
78
+ // undecorated version). Peaks scatter to shifted dest bins, their region of influence is
79
+ // carried along, and per-peak phase is advanced at the shifted instantaneous frequency.
80
+ let peaks = findPeaks(mag, half)
81
+ scatterLocked(mag, phase, state.first ? null : prev, state.first, peaks, ratio, ctx, syn, newMag, newPhase, peakDest, peakSynPhase, peakMag)
82
+
83
+ for (let k = 0; k <= half; k++) prev[k] = phase[k]
84
+ state.first = false
85
+
86
+ // 3. Re-impose the original vocal-tract envelope. The shift carried the envelope along
87
+ // with the pitch — output bin k carries the original envelope at k/ratio. Divide that out,
88
+ // multiply by env[k]. env was extracted from the log-magnitude average so the correction
89
+ // is already temporally stable (see step 1 above). Per-bin correction reshapes the spectral
90
+ // tilt, which is the whole point, but that reshaping is not itself energy-neutral — a sparse
91
+ // spectrum (pure tone, few-partial chord) samples env far from its own peak and comes back
92
+ // systematically quieter. Renormalize frame energy back to its pre-correction value (same
93
+ // policy as scatterLocked's own collision renormalization) so timbre reshaping doesn't leak
94
+ // into loudness: the shape of the correction survives, only its net gain is undone.
95
+ let eIn = 0
96
+ for (let k = 0; k <= half; k++) eIn += newMag[k] * newMag[k]
97
+ let eOut = 0
98
+ for (let k = 0; k <= half; k++) {
99
+ let src = k / ratio
100
+ let i = src | 0
101
+ let f = src - i
102
+ let a = env[Math.min(i, half)]
103
+ let b = env[Math.min(i + 1, half)]
104
+ let shiftedEnvK = a * (1 - f) + b * f
105
+ let corr = env[k] / Math.max(1e-8, shiftedEnvK)
106
+ if (corr > 8) corr = 8
107
+ if (corr < 0.125) corr = 0.125
108
+ newMag[k] *= corr
109
+ eOut += newMag[k] * newMag[k]
110
+ }
111
+ let g = eOut > 1e-24 && eIn > 1e-24 ? Math.sqrt(eIn / eOut) : 1
112
+ for (let k = 0; k <= half; k++) newMag[k] *= g
113
+
114
+ return { mag: newMag, phase: newPhase }
115
+ }
116
+
117
+ export default makeStftShift(process, {
118
+ deriveOpts: (opts) => ({ frameSize: opts?.frameSize ?? 2048 }),
119
+ post: (out) => out,
120
+ })
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@audio/shift-formant",
3
+ "version": "1.0.0",
4
+ "description": "Formant-preserving pitch shift via cepstral envelope re-imposition",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "index.js"
14
+ ],
15
+ "dependencies": {
16
+ "@audio/shift-core": "^1.0.0",
17
+ "fourier-transform": "^2.3.0"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "license": "MIT",
23
+ "author": "audiojs",
24
+ "homepage": "https://github.com/audiojs/shift/tree/main/packages/shift-formant#readme",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/audiojs/shift.git",
28
+ "directory": "packages/shift-formant"
29
+ },
30
+ "keywords": [
31
+ "audio",
32
+ "pitch-shift",
33
+ "formant",
34
+ "cepstral",
35
+ "voice",
36
+ "timbre",
37
+ "dsp"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ }
42
+ }