@audio/filter-diode-ladder 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/diode-ladder.js +101 -0
  2. package/package.json +38 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Diode ladder filter (Roland TB-303 / EMS VCS3 style).
3
+ * Zero-delay feedback (ZDF) via a tridiagonal solve of the 4 stages.
4
+ *
5
+ * Ref: Zavalishin, "The Art of VA Filter Design" (2012), §5.10 (unbuffered/
6
+ * loaded ladder networks).
7
+ * Pirkle, "Designing Audio Effect Plugins in C++" (2019), Ch. 10.
8
+ *
9
+ * Unlike the Moog transistor ladder — 4 stages isolated by unity-gain buffers,
10
+ * so each stage's output depends only on the stage before it — the diode
11
+ * ladder has no buffers: neighboring stages load each other, so each stage's
12
+ * drive depends on both its predecessor's output AND its successor's state.
13
+ * This couples all 4 stages into one tridiagonal system, solved once per
14
+ * sample (Thomas algorithm) instead of Moog's simple forward cascade. Each
15
+ * stage also carries its own tanh nonlinearity (the diode's I–V curve),
16
+ * closed by one global zero-delay resonance path around the whole chain.
17
+ *
18
+ * @module audio-filter/analog/diode-ladder
19
+ * @param {Float32Array|Float64Array} data - audio buffer (modified in place)
20
+ * @param {Object} params
21
+ * @param {number} [params.fc=1000] - cutoff frequency Hz
22
+ * @param {number} [params.resonance=0] - resonance 0–1 (self-oscillation ≈1.15–1.2, higher than moog-ladder's 1 — the per-stage tanh damps more; see readme)
23
+ * @param {number} [params.fs=44100] - sample rate
24
+ * @param {number} [params.drive=1] - input drive (saturation amount)
25
+ */
26
+
27
+ let {tan, tanh, PI, min} = Math
28
+
29
+ // Inter-stage loading: diode stages have no buffers, so a fraction of each
30
+ // neighbor's signal bleeds across the stage boundary (unlike moog-ladder's 0).
31
+ let C = 0.1
32
+
33
+ export default function diodeLadder (data, params) {
34
+ let fc = params.fc || 1000
35
+ let res = params.resonance != null ? params.resonance : 0
36
+ let fs = params.fs || 44100
37
+ let drive = params.drive ?? 1
38
+
39
+ // Trapezoidal integrator coefficient
40
+ let g = tan(PI * min(fc, fs * 0.49) / fs)
41
+ let G = g / (1 + g)
42
+ let k = res * 4 // feedback coefficient, same convention as moog-ladder
43
+
44
+ // Tridiagonal system for the 4 bidirectionally-coupled stages:
45
+ // (1+a)y1 - a*y2 = d1
46
+ // -G*y1 + (1+a)y2 - a*y3 = d2
47
+ // -G*y2 + (1+a)y3 - a*y4 = d3
48
+ // -G*y3 + y4 = d4
49
+ // a = G*C couples each stage to its neighbor. Elimination coefficients
50
+ // (Thomas algorithm) depend only on G, C — precomputed once per block.
51
+ let a = G * C
52
+ let den1 = 1 + a
53
+ let cp1 = -a / den1
54
+ let den2 = den1 + G * cp1
55
+ let cp2 = -a / den2
56
+ let den3 = den1 + G * cp2
57
+ let cp3 = -a / den3
58
+ let den4 = 1 + G * cp3
59
+
60
+ // solve(d1..d4) -> [y1,y2,y3,y4]; reused for both the zero-delay estimate
61
+ // (from old state) and the final post-feedback pass.
62
+ let out = new Float64Array(4)
63
+ function solve (d1, d2, d3, d4) {
64
+ let dp1 = d1 / den1
65
+ let dp2 = (d2 + G * dp1) / den2
66
+ let dp3 = (d3 + G * dp2) / den3
67
+ let dp4 = (d4 + G * dp3) / den4
68
+ out[3] = dp4
69
+ out[2] = dp3 - cp3 * out[3]
70
+ out[1] = dp2 - cp2 * out[2]
71
+ out[0] = dp1 - cp1 * out[1]
72
+ return out
73
+ }
74
+
75
+ // Sensitivity of y4 to a unit change in the post-feedback drive (constant per block)
76
+ let sens = solve(G, 0, 0, 0)[3]
77
+
78
+ // State: 4 one-pole integrator states
79
+ if (!params._s) params._s = new Float64Array(4)
80
+ let s = params._s
81
+
82
+ for (let i = 0, n = data.length; i < n; i++) {
83
+ let t0 = tanh(s[0]), t1 = tanh(s[1]), t2 = tanh(s[2]), t3 = tanh(s[3])
84
+ let d0 = (1 - G) * t0, d1 = (1 - G) * t1, d2 = (1 - G) * t2, d3 = (1 - G) * t3
85
+
86
+ // Zero-input estimate (S) of stage-4 output, from old state only
87
+ let S = solve(d0, d1, d2, d3)[3]
88
+
89
+ // Implicit feedback solve, then input drive/saturation
90
+ let u = (data[i] - k * S) / (1 + k * sens)
91
+ u = tanh(u * drive)
92
+
93
+ // Final coupled solve with the resolved drive
94
+ let y = solve(d0 + G * u, d1, d2, d3)
95
+ for (let j = 0; j < 4; j++) s[j] = 2 * y[j] - s[j]
96
+
97
+ data[i] = y[3]
98
+ }
99
+
100
+ return data
101
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@audio/filter-diode-ladder",
3
+ "version": "1.0.0",
4
+ "description": "Diode ladder filter (Roland TB-303 / EMS VCS3 style)",
5
+ "type": "module",
6
+ "sideEffects": false,
7
+ "main": "diode-ladder.js",
8
+ "exports": {
9
+ ".": "./diode-ladder.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "diode-ladder.js"
14
+ ],
15
+ "keywords": [
16
+ "audio",
17
+ "dsp",
18
+ "filter",
19
+ "diode-ladder"
20
+ ],
21
+ "license": "MIT",
22
+ "author": "Dmitry Iv. <dfcreative@gmail.com>",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/audiojs/filter.git",
26
+ "directory": "packages/filter-diode-ladder"
27
+ },
28
+ "homepage": "https://github.com/audiojs/filter/tree/main/packages/filter-diode-ladder",
29
+ "bugs": {
30
+ "url": "https://github.com/audiojs/filter/issues"
31
+ },
32
+ "engines": {
33
+ "node": ">=18"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }