codexfrequency-modulation-sideband-amplitude-comparison.py
frequency-modulation-sideband-amplitude-comparison.py
"""Compare original FM line spectra; emit the matching PNG in the CWD.
Integer-order Bessel coefficients use their periodic Fourier integral, with
no SciPy. Real-signal positive-frequency folding is included, even though
its correction is tiny for the frequencies displayed here.
Tested with Python 3.14, NumPy 2.3.5 and Matplotlib 3.10.7.
"""
from pathlib import Path
import matplotlib.pyplot as plt
import numpy as np
def bessel_coefficients(beta, orders):
"""J_k(beta) = mean exp(i(beta sin theta - k theta)), real part."""
theta = 2 * np.pi * np.arange(8192) / 8192
return np.cos(beta * np.sin(theta)[None, :] - orders[:, None] * theta).mean(axis=1)
def main():
fc, fm = 20, 1
frequencies = np.arange(8, 33)
betas = (0.5, 2.404825557695773, 5.0)
colors = ("#205a8c", "#9b4868", "#217865")
plt.rcParams.update({"font.size": 11, "axes.spines.top": False,
"axes.spines.right": False})
fig, axes = plt.subplots(3, 1, figsize=(9.8, 7.5), dpi=110,
sharex=True, sharey=True, layout="constrained")
fig.set_facecolor("white")
fig.suptitle("FM sidebands: changing the modulation index redistributes amplitude", fontsize=14)
for ax, beta, color in zip(axes, betas, colors):
coefficients = (bessel_coefficients(beta, frequencies - fc)
+ bessel_coefficients(beta, -frequencies - fc))
amplitudes = np.abs(coefficients)
ax.set_facecolor("white")
ax.axvspan(fc - beta * fm, fc + beta * fm, color="#e5edf2", zorder=0)
ax.axvline(fc, color="#697884", ls="--", lw=1)
ax.vlines(frequencies, 0, amplitudes, color=color, lw=2)
ax.scatter(frequencies, amplitudes, color=color, s=13, zorder=3)
ax.set_ylim(-0.025, 1.05)
ax.set_xlim(7.5, 32.5)
ax.set_yticks([0, 0.5, 1])
ax.set_ylabel("Amplitude / A")
ax.grid(axis="y", color="#e4e7eb", lw=0.8)
label = f"β = {beta:.1f}" if beta != betas[1] else "β ≈ 2.4048256"
ax.set_title(label + " | envelope amplitude = A", fontsize=11, loc="left")
axes[0].text(23, 0.81, "Most amplitude remains in the carrier", color=colors[0])
axes[1].annotate("Carrier component nearly vanishes", xy=(20, 0), xytext=(22.7, 0.78),
arrowprops={"arrowstyle": "->", "color": colors[1]}, color=colors[1])
axes[2].annotate("26 Hz lies outside the 15–25 Hz range", xy=(26, 0.1310487318),
xytext=(19.8, 0.82), arrowprops={"arrowstyle": "->", "color": colors[2]},
color=colors[2])
axes[2].set_xticks(np.arange(8, 33, 2))
axes[2].set_xlabel("Frequency [Hz] (carrier = 20 Hz; message = 1 Hz)")
fig.supxlabel("Shading: instantaneous-frequency range. Lines: one-sided cosine amplitudes, not power.\n"
"Only 8–32 Hz shown; the ideal FM spectrum has infinitely many lines.", fontsize=10)
fig.savefig(Path(__file__).stem + ".png", facecolor="white", transparent=False)
plt.close(fig)
if __name__ == "__main__":
main()