@audio/shift-lpc 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 +106 -0
  2. package/package.json +42 -0
package/index.js ADDED
@@ -0,0 +1,106 @@
1
+ import { bufferedStream, hannWindow, makePitchShift, matchGain, resolveRatio } from '@audio/shift-core'
2
+ import delay from '@audio/shift-delay'
3
+
4
+ // Canonical LPC source-filter pitch shift (residual-excited linear prediction, RELP
5
+ // lineage): per frame, estimate the vocal-tract all-pole filter A(z) by the
6
+ // autocorrelation method (Levinson-Durbin), inverse-filter the signal to its
7
+ // spectrally-flat excitation residual, repitch the residual with the delay-line
8
+ // splicer (@audio/shift-delay — duration-preserving, and its correlation splicing
9
+ // lands on the residual's pitch pulses), and resynthesize through the UNMODIFIED
10
+ // 1/A(z) run continuously with block-switched coefficients — the formant envelope
11
+ // never moves, by construction. The classical speech-processing complement to
12
+ // cepstral envelope preservation (@audio/shift-formant): exact on voiced monophonic
13
+ // material, graceful-but-averaged on polyphony. Degenerate on narrowband/pure tones —
14
+ // there the AR envelope IS the partial, so the filter re-imposes the original pitch;
15
+ // that is the family's defining tradeoff, not a bug. `order` defaults to the classical
16
+ // 2 + sr/1000, capped so pole pairs model the envelope, not individual harmonics.
17
+
18
+ function levinson(r, p, a, tmp) {
19
+ a.fill(0)
20
+ a[0] = 1
21
+ let err = r[0]
22
+ for (let i = 1; i <= p; i++) {
23
+ let acc = r[i]
24
+ for (let j = 1; j < i; j++) acc += a[j] * r[i - j]
25
+ let k = -acc / err
26
+ for (let j = 0; j <= i; j++) tmp[j] = a[j] + k * a[i - j]
27
+ for (let j = 0; j <= i; j++) a[j] = tmp[j]
28
+ err *= 1 - k * k
29
+ if (err < 1e-12) { err = 1e-12; break }
30
+ }
31
+ return err
32
+ }
33
+
34
+ function lpcBatch(data, opts) {
35
+ let { ratio, ratioFn } = resolveRatio(opts)
36
+ let n = data.length
37
+ let N = opts?.frameSize ?? 1024
38
+ let hop = N >> 2
39
+ let sr = opts?.sampleRate || 44100
40
+ let p = opts?.order ?? Math.min(N >> 4, Math.round(2 + sr / 1000))
41
+ let out = new Float32Array(n)
42
+ if (!n) return out
43
+
44
+ let win = hannWindow(N)
45
+ let frames = Math.ceil(n / hop)
46
+ let coefs = new Float64Array(frames * (p + 1))
47
+ let xw = new Float64Array(N)
48
+ let r = new Float64Array(p + 1)
49
+ let a = new Float64Array(p + 1)
50
+ let tmp = new Float64Array(p + 1)
51
+
52
+ for (let f = 0; f < frames; f++) {
53
+ // Clamp the fit window inside the signal: a zero-padded tail window fits the fade,
54
+ // whitens poorly, and the hot residual tail then rings the synthesis filter.
55
+ let start = Math.max(0, Math.min(f * hop, n - N))
56
+ for (let u = 0; u < N; u++) {
57
+ let t = start + u
58
+ xw[u] = t < n ? data[t] * win[u] : 0
59
+ }
60
+ for (let k = 0; k <= p; k++) {
61
+ let s = 0
62
+ for (let u = k; u < N; u++) s += xw[u] * xw[u - k]
63
+ r[k] = s
64
+ }
65
+ r[0] *= 1.000001
66
+ if (r[0] < 1e-12) { coefs[f * (p + 1)] = 1; continue }
67
+ levinson(r, p, a, tmp)
68
+ coefs.set(a, f * (p + 1))
69
+ }
70
+
71
+ // Inverse-filter with block-switched coefficients: e = A(z)·x, state continuous.
72
+ let res = new Float32Array(n)
73
+ for (let t = 0; t < n; t++) {
74
+ let base = Math.min(frames - 1, (t / hop) | 0) * (p + 1)
75
+ let e = data[t]
76
+ for (let j = 1; j <= p; j++) {
77
+ let tj = t - j
78
+ if (tj >= 0) e += coefs[base + j] * data[tj]
79
+ }
80
+ res[t] = e
81
+ }
82
+
83
+ // Repitch the flat residual, then run 1/A(z) continuously over it — no grains, no
84
+ // synthesis transients; each sample uses its own frame's coefficients.
85
+ let shifted = delay(res, { ...opts, ratio: ratioFn || ratio })
86
+ for (let t = 0; t < n; t++) {
87
+ let base = Math.min(frames - 1, (t / hop) | 0) * (p + 1)
88
+ let y = shifted[t]
89
+ for (let j = 1; j <= p; j++) {
90
+ let tj = t - j
91
+ if (tj >= 0) y -= coefs[base + j] * out[tj]
92
+ }
93
+ out[t] = y
94
+ }
95
+
96
+ // Harmonics moving across the AR response change level with position; a single
97
+ // whole-signal RMS match restores loudness without touching the envelope shape
98
+ // (time-varying gain would ring AM sidebands through every harmonic). The stream
99
+ // form is bufferedStream — whole-signal at flush — so batch and stream stay
100
+ // identical even with a global correction.
101
+ return matchGain(out, data)
102
+ }
103
+
104
+ let lpcStream = (opts) => bufferedStream(lpcBatch, opts)
105
+
106
+ export default makePitchShift(lpcBatch, lpcStream)
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@audio/shift-lpc",
3
+ "version": "1.0.0",
4
+ "description": "LPC source-filter pitch shift — repitched excitation residual through the unmodified all-pole vocal-tract filter",
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
+ "@audio/shift-delay": "^1.0.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-lpc#readme",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "git+https://github.com/audiojs/shift.git",
28
+ "directory": "packages/shift-lpc"
29
+ },
30
+ "keywords": [
31
+ "audio",
32
+ "pitch-shift",
33
+ "lpc",
34
+ "linear-prediction",
35
+ "formant",
36
+ "source-filter",
37
+ "dsp"
38
+ ],
39
+ "engines": {
40
+ "node": ">=18"
41
+ }
42
+ }