Fourier Series
Every periodic signal is a sum of sines.
Intermediate / approximation / trigonometry
A square wave's Fourier series is a sum of odd harmonics with 1/n weights; truncating it gives an approximation whose overshoot at the jump never disappears - Gibbs' phenomenon, visible on the plot. Add terms and watch the error fall while the ringing squeezes toward the edge.
Tagged: square wave, Gibbs phenomenon, harmonics, synthesis
Live simulation
More terms squeeze the ringing at the jump; it never disappears (Gibbs). Keyboard: Space runs or pauses, . steps, r resets.
Speed
More terms squeeze the ringing at the jump; it never disappears (Gibbs).
Mathematics
Periodic functions decompose into sine harmonics: the square wave is (4/pi) times the odd harmonics with 1/n weights, the sawtooth uses every harmonic with alternating signs, the triangle uses 1/n-squared. Truncating the series gives an approximation whose error you can measure.
square: f(t) = (4/pi) sum_{k odd} sin(k wt) / k
sawtooth: f(t) = (2/pi) sum_{k>=1} sin(k wt) / k
triangle: f(t) = (8/pi^2) sum_{k odd} (-1)^((k-1)/2) sin(k wt) / k^2
Gibbs' phenomenon is the star of the square wave: near the discontinuity the partial sum overshoots by about 9% no matter how many terms you add - more terms squeeze the ringing narrower, they never shrink it. The max-error metric deliberately skips the jump point to measure the interior approximation honestly.
How it works
Both the target and the truncated series are evaluated sample-by-sample across the view; the measured max error sweeps 400 interior points so the reported number cannot hide a spike.
Change the waveform and the coefficient recipe changes with it - the plot code is identical.
Implementation
src/engine/experiments/fourier-series.ts · 130 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import type { ParameterDef, ThemeColors } from "../core/types"; |
| 3 | |
| 4 | /** |
| 5 | * Fourier series synthesis: the chosen target waveform is its Fourier series |
| 6 | * truncated to N terms. The approximation and its measured error are drawn |
| 7 | * together, so Gibbs' ringing at the square wave's jump is visible, not hidden. |
| 8 | */ |
| 9 | export class FourierSeriesExperiment extends BaseExperiment { |
| 10 | readonly id = "fourier-series"; |
| 11 | |
| 12 | protected params(): ParameterDef[] { |
| 13 | return [ |
| 14 | { |
| 15 | key: "wave", |
| 16 | label: "Target waveform", |
| 17 | options: [ |
| 18 | { value: "square", label: "Square wave" }, |
| 19 | { value: "sawtooth", label: "Sawtooth" }, |
| 20 | { value: "triangle", label: "Triangle wave" }, |
| 21 | ], |
| 22 | defaultValue: "square", |
| 23 | }, |
| 24 | { key: "terms", label: "Series terms", min: 1, max: 25, step: 1, defaultValue: 5 }, |
| 25 | { key: "cycles", label: "Cycles across view", min: 1, max: 4, step: 1, defaultValue: 2 }, |
| 26 | ]; |
| 27 | } |
| 28 | |
| 29 | protected onReset(): void {} |
| 30 | |
| 31 | protected onUpdate(): void {} |
| 32 | |
| 33 | private target(t: number): number { |
| 34 | // t in [0, 1) is one period. |
| 35 | const wave = this.str("wave"); |
| 36 | if (wave === "square") return t % 1 < 0.5 ? 1 : -1; |
| 37 | if (wave === "sawtooth") return 1 - 2 * (t % 1); |
| 38 | // Triangle, zero at t = 0, rising: matches the standard 1/n^2 odd series. |
| 39 | return t < 0.25 ? 4 * t : t < 0.75 ? 2 - 4 * t : 4 * t - 4; |
| 40 | } |
| 41 | |
| 42 | private partial(t: number): number { |
| 43 | const terms = this.num("terms"); |
| 44 | const wave = this.str("wave"); |
| 45 | let sum = 0; |
| 46 | const w = 2 * Math.PI; |
| 47 | if (wave === "square") { |
| 48 | for (let k = 0; k < terms; k++) { |
| 49 | const n = 2 * k + 1; |
| 50 | sum += Math.sin(n * w * t) / n; |
| 51 | } |
| 52 | return (4 / Math.PI) * sum; |
| 53 | } |
| 54 | if (wave === "sawtooth") { |
| 55 | for (let k = 1; k <= terms; k++) { |
| 56 | sum += Math.sin(k * w * t) / k; |
| 57 | } |
| 58 | return (2 / Math.PI) * sum; |
| 59 | } |
| 60 | // Triangle: odd harmonics with alternating signs, 1/n^2. |
| 61 | for (let k = 0; k < terms; k++) { |
| 62 | const n = 2 * k + 1; |
| 63 | sum += ((-1) ** k * Math.sin(n * w * t)) / (n * n); |
| 64 | } |
| 65 | return (8 / (Math.PI * Math.PI)) * sum; |
| 66 | } |
| 67 | |
| 68 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 69 | ctx.fillStyle = theme.bg; |
| 70 | ctx.fillRect(0, 0, this.width, this.height); |
| 71 | const mid = this.height / 2; |
| 72 | const amp = this.height / 2.6; |
| 73 | const cycles = this.num("cycles"); |
| 74 | ctx.strokeStyle = theme.line; |
| 75 | ctx.beginPath(); |
| 76 | ctx.moveTo(0, mid); |
| 77 | ctx.lineTo(this.width, mid); |
| 78 | ctx.stroke(); |
| 79 | const draw = (fn: (t: number) => number, color: string, width: number) => { |
| 80 | ctx.strokeStyle = color; |
| 81 | ctx.lineWidth = width; |
| 82 | ctx.beginPath(); |
| 83 | for (let px = 0; px <= this.width; px += 1) { |
| 84 | const t = (px / this.width) * cycles; |
| 85 | const y = mid - fn(t % 1) * amp; |
| 86 | if (px === 0) ctx.moveTo(px, y); |
| 87 | else ctx.lineTo(px, y); |
| 88 | } |
| 89 | ctx.stroke(); |
| 90 | }; |
| 91 | draw((t) => this.target(t), theme.fgTertiary, 1.25); |
| 92 | draw((t) => this.partial(t), theme.accent, 2); |
| 93 | ctx.font = "11px ui-monospace, monospace"; |
| 94 | ctx.fillStyle = theme.fgTertiary; |
| 95 | ctx.fillText("TARGET", 10, 16); |
| 96 | ctx.fillStyle = theme.accent; |
| 97 | ctx.fillText(`${this.num("terms")}-TERM SERIES`, 66, 16); |
| 98 | } |
| 99 | |
| 100 | getMetrics() { |
| 101 | let err = 0; |
| 102 | for (let i = 0; i < 400; i++) { |
| 103 | const t = i / 400; |
| 104 | // Skip the discontinuity neighborhood for the square wave. |
| 105 | // Skip the Gibbs ringing band around the square wave's jump; the |
| 106 | // interior error is the honest measurement. |
| 107 | // Skip the Gibbs ringing band around each jump of the target wave. |
| 108 | const periodPosition = t % 1; |
| 109 | const jumpDistance = this.str("wave") === "square" |
| 110 | ? Math.min(periodPosition, Math.abs(periodPosition - 0.5), 1 - periodPosition) |
| 111 | : Math.min(periodPosition, 1 - periodPosition); |
| 112 | if (this.str("wave") !== "triangle" && jumpDistance < 0.08) continue; |
| 113 | err = Math.max(err, Math.abs(this.partial(t) - this.target(t))); |
| 114 | } |
| 115 | return { |
| 116 | waveform: this.str("wave"), |
| 117 | terms: this.num("terms"), |
| 118 | maxError: err.toExponential(2), |
| 119 | }; |
| 120 | } |
| 121 | |
| 122 | describe(): string { |
| 123 | return `The ${this.str("wave")} wave approximated by a ${this.num("terms")}-term Fourier series; maximum sampled error ${this.getMetrics().maxError}.`; |
| 124 | } |
| 125 | |
| 126 | entities(): number { |
| 127 | return this.num("terms"); |
| 128 | } |
| 129 | } |
| 130 | |
Performance
- Time per tick
- O(terms) per drawn sample
- Space
- None
- Notes
- The triangle wave converges fastest: its coefficients fall like 1/n^2 because the function is continuous.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.