Skip to content

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).

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

1import { BaseExperiment } from "../core/base-experiment";
2import 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 */
9export 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.