@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.
- package/index.js +120 -0
- 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
|
+
}
|