|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | + <meta charset="UTF-8" /> |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| 6 | + <title>tsb — Digital Filters</title> |
| 7 | + <style> |
| 8 | + :root { |
| 9 | + --bg: #0d1117; |
| 10 | + --surface: #161b22; |
| 11 | + --border: #30363d; |
| 12 | + --text: #e6edf3; |
| 13 | + --accent: #58a6ff; |
| 14 | + --green: #3fb950; |
| 15 | + --orange: #d29922; |
| 16 | + --red: #f85149; |
| 17 | + --font-mono: "Cascadia Code", "Fira Code", "JetBrains Mono", monospace; |
| 18 | + } |
| 19 | + * { box-sizing: border-box; margin: 0; padding: 0; } |
| 20 | + body { |
| 21 | + background: var(--bg); |
| 22 | + color: var(--text); |
| 23 | + font-family: system-ui, -apple-system, sans-serif; |
| 24 | + line-height: 1.6; |
| 25 | + padding: 2rem; |
| 26 | + max-width: 900px; |
| 27 | + margin: 0 auto; |
| 28 | + } |
| 29 | + a { color: var(--accent); } |
| 30 | + h1 { color: var(--accent); margin-bottom: 0.5rem; } |
| 31 | + h2 { color: var(--text); margin: 2rem 0 0.5rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; } |
| 32 | + h3 { color: var(--accent); margin: 1.5rem 0 0.5rem; } |
| 33 | + pre { |
| 34 | + background: var(--surface); |
| 35 | + border: 1px solid var(--border); |
| 36 | + border-radius: 6px; |
| 37 | + padding: 1rem; |
| 38 | + overflow-x: auto; |
| 39 | + font-family: var(--font-mono); |
| 40 | + font-size: 0.85rem; |
| 41 | + line-height: 1.5; |
| 42 | + } |
| 43 | + code { font-family: var(--font-mono); font-size: 0.9em; } |
| 44 | + .example { margin: 1.5rem 0; } |
| 45 | + .output { |
| 46 | + background: #0a2a0a; |
| 47 | + border: 1px solid var(--green); |
| 48 | + border-radius: 6px; |
| 49 | + padding: 0.75rem 1rem; |
| 50 | + font-family: var(--font-mono); |
| 51 | + font-size: 0.85rem; |
| 52 | + margin-top: 0.5rem; |
| 53 | + white-space: pre-wrap; |
| 54 | + } |
| 55 | + table { border-collapse: collapse; width: 100%; margin: 1rem 0; } |
| 56 | + th, td { border: 1px solid var(--border); padding: 0.5rem 1rem; text-align: left; } |
| 57 | + th { background: var(--surface); color: var(--accent); } |
| 58 | + </style> |
| 59 | +</head> |
| 60 | +<body> |
| 61 | + <h1>🎛️ Digital Filters</h1> |
| 62 | + <p>FIR and IIR filter design and application — mirrors <code>scipy.signal</code>.</p> |
| 63 | + |
| 64 | + <h2>FIR Filters</h2> |
| 65 | + |
| 66 | + <h3>1. Low-pass FIR with firwin</h3> |
| 67 | + <div class="example"> |
| 68 | + <pre>import { firwin, freqz, cAbs } from "tsb"; |
| 69 | + |
| 70 | +// 51-tap Hamming-windowed low-pass at 0.3 * Nyquist |
| 71 | +const b = firwin(51, 0.3); |
| 72 | +console.log(`Coefficients: ${b.length} taps`); |
| 73 | +console.log(`DC gain: ${b.reduce((s, v) => s + v, 0).toFixed(6)}`); |
| 74 | + |
| 75 | +// Frequency response |
| 76 | +const { w, H } = freqz(b, [1], 512); |
| 77 | +const mag = H.map(cAbs); |
| 78 | + |
| 79 | +// DC (w=0): should be ≈ 1.0 |
| 80 | +console.log(`Gain at DC: ${mag[0].toFixed(4)}`); |
| 81 | +// Nyquist (w=π): should be ≈ 0 |
| 82 | +console.log(`Gain at Nyquist: ${mag[511].toFixed(4)}`);</pre> |
| 83 | + <div class="output" id="out1">Running…</div> |
| 84 | + </div> |
| 85 | + |
| 86 | + <h3>2. High-pass FIR</h3> |
| 87 | + <div class="example"> |
| 88 | + <pre>import { firwin, freqz, cAbs } from "tsb"; |
| 89 | + |
| 90 | +// 51-tap high-pass, pass_zero=false |
| 91 | +const b = firwin(51, 0.3, { pass_zero: false }); |
| 92 | +const { H } = freqz(b, [1], 512); |
| 93 | +const mag = H.map(cAbs); |
| 94 | + |
| 95 | +console.log(`Gain at DC: ${mag[0].toFixed(4)} (should be ≈ 0)`); |
| 96 | +console.log(`Gain at Nyquist: ${mag[511].toFixed(4)} (should be ≈ 1)`);</pre> |
| 97 | + <div class="output" id="out2">Running…</div> |
| 98 | + </div> |
| 99 | + |
| 100 | + <h3>3. Apply FIR filter with lfilter and filtfilt</h3> |
| 101 | + <div class="example"> |
| 102 | + <pre>import { firwin, lfilter, filtfilt } from "tsb"; |
| 103 | + |
| 104 | +const fs = 512, n = 512; |
| 105 | +// Mix 20 Hz (keep) + 200 Hz (remove) signals |
| 106 | +const x = Array.from({ length: n }, (_, i) => |
| 107 | + Math.sin(2 * Math.PI * 20 * i / fs) + |
| 108 | + Math.sin(2 * Math.PI * 200 * i / fs), |
| 109 | +); |
| 110 | + |
| 111 | +// Low-pass FIR: cutoff at 100 Hz |
| 112 | +const b = firwin(63, 100, { fs }); |
| 113 | + |
| 114 | +// Causal filter (introduces phase delay) |
| 115 | +const yLf = lfilter(b, [1], x); |
| 116 | + |
| 117 | +// Zero-phase filter (no phase delay) |
| 118 | +const yFf = filtfilt(b, [1], x); |
| 119 | + |
| 120 | +const mid = 256; |
| 121 | +console.log(`Input at mid-point: ${x[mid].toFixed(4)}`); |
| 122 | +console.log(`lfilter output (causal): ${yLf[mid].toFixed(4)}`); |
| 123 | +console.log(`filtfilt output (no delay): ${yFf[mid].toFixed(4)}`);</pre> |
| 124 | + <div class="output" id="out3">Running…</div> |
| 125 | + </div> |
| 126 | + |
| 127 | + <h2>IIR Filters — Butterworth</h2> |
| 128 | + |
| 129 | + <h3>4. Design a Butterworth low-pass filter</h3> |
| 130 | + <div class="example"> |
| 131 | + <pre>import { butter, sosfreqz, cAbs } from "tsb"; |
| 132 | + |
| 133 | +// 4th-order Butterworth, cutoff at 0.3 * Nyquist |
| 134 | +const { sos, b, a } = butter(4, 0.3, "lowpass"); |
| 135 | +console.log(`SOS sections: ${sos.length}`); |
| 136 | +console.log(`b coefficients: [${b.map(v => v.toFixed(6)).join(", ")}]`); |
| 137 | +console.log(`a coefficients: [${a.map(v => v.toFixed(6)).join(", ")}]`); |
| 138 | + |
| 139 | +// Frequency response via SOS |
| 140 | +const { H } = sosfreqz(sos, 512); |
| 141 | +const mag = H.map(cAbs); |
| 142 | +console.log(`DC gain: ${mag[0].toFixed(4)} (should be ≈ 1.0)`); |
| 143 | +console.log(`At cutoff (~bin 77): ${mag[77].toFixed(4)} (≈ 0.707 = -3dB)`);</pre> |
| 144 | + <div class="output" id="out4">Running…</div> |
| 145 | + </div> |
| 146 | + |
| 147 | + <h3>5. Apply Butterworth filter with sosfilt</h3> |
| 148 | + <div class="example"> |
| 149 | + <pre>import { butter, sosfilt, sosfiltfilt } from "tsb"; |
| 150 | + |
| 151 | +const fs = 1000, n = 1000; |
| 152 | +// Noisy 50 Hz signal |
| 153 | +const x = Array.from({ length: n }, (_, i) => |
| 154 | + Math.sin(2 * Math.PI * 50 * i / fs) + |
| 155 | + 0.5 * Math.sin(2 * Math.PI * 300 * i / fs), // high-freq noise |
| 156 | +); |
| 157 | + |
| 158 | +// 4th-order Butterworth low-pass at 150 Hz |
| 159 | +const { sos } = butter(4, 150 / (fs / 2), "lowpass"); |
| 160 | + |
| 161 | +// Causal |
| 162 | +const y1 = sosfilt(sos, x); |
| 163 | +// Zero-phase |
| 164 | +const y2 = sosfiltfilt(sos, x); |
| 165 | + |
| 166 | +const mid = 500; |
| 167 | +const ref = Math.sin(2 * Math.PI * 50 * mid / fs); |
| 168 | +console.log(`Reference (50 Hz): ${ref.toFixed(4)}`); |
| 169 | +console.log(`sosfilt output: ${y1[mid].toFixed(4)}`); |
| 170 | +console.log(`sosfiltfilt output: ${y2[mid].toFixed(4)} (zero-phase, closer to ref)`);</pre> |
| 171 | + <div class="output" id="out5">Running…</div> |
| 172 | + </div> |
| 173 | + |
| 174 | + <h3>6. High-pass Butterworth</h3> |
| 175 | + <div class="example"> |
| 176 | + <pre>import { butter, sosfreqz, cAbs } from "tsb"; |
| 177 | + |
| 178 | +const { sos } = butter(2, 0.3, "highpass"); |
| 179 | +const { w, H } = sosfreqz(sos, 512); |
| 180 | +const mag = H.map(cAbs); |
| 181 | + |
| 182 | +console.log(`Gain at DC (w=0): ${mag[0].toFixed(4)} (should be ≈ 0)`); |
| 183 | +console.log(`Gain at Nyquist (w=π): ${mag[511].toFixed(4)} (should be ≈ 1.0)`);</pre> |
| 184 | + <div class="output" id="out6">Running…</div> |
| 185 | + </div> |
| 186 | + |
| 187 | + <h3>7. Filter frequency response — multiple orders</h3> |
| 188 | + <div class="example"> |
| 189 | + <pre>import { butter, sosfreqz, cAbs } from "tsb"; |
| 190 | + |
| 191 | +// Compare -3dB attenuation at cutoff for different orders |
| 192 | +const Wn = 0.3; |
| 193 | +console.log("Butterworth -3dB gain at cutoff frequency:"); |
| 194 | +for (const N of [1, 2, 4, 6, 8]) { |
| 195 | + const { sos } = butter(N, Wn, "lowpass"); |
| 196 | + // Evaluate at ω = Wn * π (the digital cutoff frequency) |
| 197 | + const { H } = sosfreqz(sos, [Wn * Math.PI]); |
| 198 | + const gain = cAbs(H[0]); |
| 199 | + const dB = 20 * Math.log10(gain); |
| 200 | + console.log(` N=${N}: gain=${gain.toFixed(4)} (${dB.toFixed(2)} dB)`); |
| 201 | +}</pre> |
| 202 | + <div class="output" id="out7">Running…</div> |
| 203 | + </div> |
| 204 | + |
| 205 | + <h2>API Reference</h2> |
| 206 | + <table> |
| 207 | + <tr><th>Function</th><th>Description</th><th>Mirrors</th></tr> |
| 208 | + <tr><td><code>firwin(n, cutoff, opts?)</code></td><td>FIR design (windowed-sinc)</td><td><code>scipy.signal.firwin</code></td></tr> |
| 209 | + <tr><td><code>butter(N, Wn, type?)</code></td><td>Butterworth IIR design</td><td><code>scipy.signal.butter</code></td></tr> |
| 210 | + <tr><td><code>freqz(b, a?, worN?)</code></td><td>FIR/IIR frequency response</td><td><code>scipy.signal.freqz</code></td></tr> |
| 211 | + <tr><td><code>sosfreqz(sos, worN?)</code></td><td>SOS frequency response</td><td><code>scipy.signal.sosfreqz</code></td></tr> |
| 212 | + <tr><td><code>lfilter(b, a, x)</code></td><td>Causal FIR/IIR filter</td><td><code>scipy.signal.lfilter</code></td></tr> |
| 213 | + <tr><td><code>filtfilt(b, a, x)</code></td><td>Zero-phase filter</td><td><code>scipy.signal.filtfilt</code></td></tr> |
| 214 | + <tr><td><code>sosfilt(sos, x)</code></td><td>Causal SOS filter</td><td><code>scipy.signal.sosfilt</code></td></tr> |
| 215 | + <tr><td><code>sosfiltfilt(sos, x)</code></td><td>Zero-phase SOS filter</td><td><code>scipy.signal.sosfiltfilt</code></td></tr> |
| 216 | + </table> |
| 217 | + |
| 218 | + <script type="module"> |
| 219 | + import * as tsb from "https://esm.sh/tsb@latest"; |
| 220 | + |
| 221 | + function run(id, fn) { |
| 222 | + const el = document.getElementById(id); |
| 223 | + try { |
| 224 | + const logs = []; |
| 225 | + const origLog = console.log; |
| 226 | + console.log = (...args) => { logs.push(args.map(String).join(" ")); origLog(...args); }; |
| 227 | + fn(tsb); |
| 228 | + console.log = origLog; |
| 229 | + el.textContent = logs.join("\n") || "(no output)"; |
| 230 | + el.style.borderColor = "#3fb950"; |
| 231 | + } catch (err) { |
| 232 | + el.textContent = "Error: " + err.message; |
| 233 | + el.style.borderColor = "#f85149"; |
| 234 | + } |
| 235 | + } |
| 236 | + |
| 237 | + run("out1", (tsb) => { |
| 238 | + const b = tsb.firwin(51, 0.3); |
| 239 | + console.log(`Coefficients: ${b.length} taps`); |
| 240 | + console.log(`DC gain: ${b.reduce((s, v) => s + v, 0).toFixed(6)}`); |
| 241 | + const { H } = tsb.freqz(b, [1], 512); |
| 242 | + const mag = H.map(tsb.cAbs); |
| 243 | + console.log(`Gain at DC: ${mag[0].toFixed(4)}`); |
| 244 | + console.log(`Gain at Nyquist: ${mag[511].toFixed(4)}`); |
| 245 | + }); |
| 246 | + |
| 247 | + run("out2", (tsb) => { |
| 248 | + const b = tsb.firwin(51, 0.3, { pass_zero: false }); |
| 249 | + const { H } = tsb.freqz(b, [1], 512); |
| 250 | + const mag = H.map(tsb.cAbs); |
| 251 | + console.log(`Gain at DC: ${mag[0].toFixed(4)} (should be ≈ 0)`); |
| 252 | + console.log(`Gain at Nyquist: ${mag[511].toFixed(4)} (should be ≈ 1)`); |
| 253 | + }); |
| 254 | + |
| 255 | + run("out3", (tsb) => { |
| 256 | + const fs = 512, n = 512; |
| 257 | + const x = Array.from({ length: n }, (_, i) => |
| 258 | + Math.sin(2 * Math.PI * 20 * i / fs) + Math.sin(2 * Math.PI * 200 * i / fs), |
| 259 | + ); |
| 260 | + const b = tsb.firwin(63, 100, { fs }); |
| 261 | + const yLf = tsb.lfilter(b, [1], x); |
| 262 | + const yFf = tsb.filtfilt(b, [1], x); |
| 263 | + const mid = 256; |
| 264 | + console.log(`Input at mid-point: ${x[mid].toFixed(4)}`); |
| 265 | + console.log(`lfilter output (causal): ${yLf[mid].toFixed(4)}`); |
| 266 | + console.log(`filtfilt output (no delay): ${yFf[mid].toFixed(4)}`); |
| 267 | + }); |
| 268 | + |
| 269 | + run("out4", (tsb) => { |
| 270 | + const { sos, b, a } = tsb.butter(4, 0.3, "lowpass"); |
| 271 | + console.log(`SOS sections: ${sos.length}`); |
| 272 | + console.log(`b: [${b.map(v => v.toFixed(6)).join(", ")}]`); |
| 273 | + console.log(`a: [${a.map(v => v.toFixed(6)).join(", ")}]`); |
| 274 | + const { H } = tsb.sosfreqz(sos, 512); |
| 275 | + const mag = H.map(tsb.cAbs); |
| 276 | + console.log(`DC gain: ${mag[0].toFixed(4)}`); |
| 277 | + }); |
| 278 | + |
| 279 | + run("out5", (tsb) => { |
| 280 | + const fs = 1000, n = 1000; |
| 281 | + const x = Array.from({ length: n }, (_, i) => |
| 282 | + Math.sin(2 * Math.PI * 50 * i / fs) + 0.5 * Math.sin(2 * Math.PI * 300 * i / fs), |
| 283 | + ); |
| 284 | + const { sos } = tsb.butter(4, 150 / (fs / 2), "lowpass"); |
| 285 | + const y1 = tsb.sosfilt(sos, x); |
| 286 | + const y2 = tsb.sosfiltfilt(sos, x); |
| 287 | + const mid = 500; |
| 288 | + const ref = Math.sin(2 * Math.PI * 50 * mid / fs); |
| 289 | + console.log(`Reference (50 Hz): ${ref.toFixed(4)}`); |
| 290 | + console.log(`sosfilt output: ${y1[mid].toFixed(4)}`); |
| 291 | + console.log(`sosfiltfilt output: ${y2[mid].toFixed(4)}`); |
| 292 | + }); |
| 293 | + |
| 294 | + run("out6", (tsb) => { |
| 295 | + const { sos } = tsb.butter(2, 0.3, "highpass"); |
| 296 | + const { H } = tsb.sosfreqz(sos, 512); |
| 297 | + const mag = H.map(tsb.cAbs); |
| 298 | + console.log(`Gain at DC: ${mag[0].toFixed(4)}`); |
| 299 | + console.log(`Gain at Nyquist: ${mag[511].toFixed(4)}`); |
| 300 | + }); |
| 301 | + |
| 302 | + run("out7", (tsb) => { |
| 303 | + const Wn = 0.3; |
| 304 | + console.log("Butterworth -3dB gain at cutoff:"); |
| 305 | + for (const N of [1, 2, 4, 6, 8]) { |
| 306 | + const { sos } = tsb.butter(N, Wn, "lowpass"); |
| 307 | + const { H } = tsb.sosfreqz(sos, [Wn * Math.PI]); |
| 308 | + const gain = tsb.cAbs(H[0]); |
| 309 | + const dB = 20 * Math.log10(gain); |
| 310 | + console.log(` N=${N}: gain=${gain.toFixed(4)} (${dB.toFixed(2)} dB)`); |
| 311 | + } |
| 312 | + }); |
| 313 | + </script> |
| 314 | +</body> |
| 315 | +</html> |
0 commit comments