Generating and Plotting an AM Wave with Matplotlib
Amplitude Modulation (AM) is a type of analog communication in which the amplitude of a carrier signal is varied in …
This post builds on AM Wave Generation and Plotting with Matplotlib. Once you can generate an AM waveform, the interesting question is: is it any good? That means measuring modulation index and inspecting the spectrum. Below is a small AdvancedAMAnalyzer class that handles both, plus a worked example of how sideband power redistributes with the modulation index.
The class holds the sample rate and duration, and exposes three operations: generate a signal, calculate the modulation index from the envelope, and analyze the sideband spectrum via FFT.
1import matplotlib.pyplot as plt
2import numpy as np
3from scipy import signal
4from scipy.fft import fft, fftfreq
5
6class AdvancedAMAnalyzer:
7 """AM signal analysis: modulation index and sideband spectrum."""
8
9 def __init__(self, sampling_rate=10000, duration=1.0):
10 self.sampling_rate = sampling_rate
11 self.duration = duration
12 self.time = np.linspace(0, duration, int(sampling_rate * duration))
13
14 def generate_am_signal(self, carrier_freq, message_freq, modulation_index,
15 message_amplitude=1.0, carrier_amplitude=1.0,
16 noise_level=0.0):
17 """Generate an AM signal with optional additive Gaussian noise."""
18 carrier = carrier_amplitude * np.sin(2 * np.pi * carrier_freq * self.time)
19 message = message_amplitude * np.sin(2 * np.pi * message_freq * self.time)
20
21 am_signal = carrier * (1 + modulation_index * message)
22
23 if noise_level > 0:
24 am_signal += noise_level * np.random.normal(0, 1, len(self.time))
25
26 return am_signal, carrier, message
27
28 def calculate_modulation_index(self, am_signal):
29 """Estimate modulation index from the signal envelope (Hilbert transform)."""
30 envelope = np.abs(signal.hilbert(am_signal))
31 max_env = np.max(envelope)
32 min_env = np.min(envelope)
33 m = (max_env - min_env) / (max_env + min_env)
34 return m, envelope
35
36 def analyze_sidebands(self, am_signal, carrier_freq, message_freq):
37 """FFT-based power at the carrier and the two sideband frequencies."""
38 fft_signal = fft(am_signal)
39 freqs = fftfreq(len(self.time), 1 / self.sampling_rate)
40
41 def power_at(f):
42 idx = np.argmin(np.abs(freqs - f))
43 return np.abs(fft_signal[idx]) ** 2
44
45 carrier_power = power_at(carrier_freq)
46 upper_sb_power = power_at(carrier_freq + message_freq)
47 lower_sb_power = power_at(carrier_freq - message_freq)
48
49 return {
50 'frequencies': freqs,
51 'fft_signal': fft_signal,
52 'carrier_power': carrier_power,
53 'upper_sideband_power': upper_sb_power,
54 'lower_sideband_power': lower_sb_power,
55 'total_power': carrier_power + upper_sb_power + lower_sb_power,
56 }
57
58analyzer = AdvancedAMAnalyzer(sampling_rate=10000, duration=1.0)
The envelope is recovered with scipy.signal.hilbert, which returns the analytic signal; the absolute value of that is the amplitude envelope. The modulation index formula (max - min) / (max + min) then falls straight out of the definition of AM: the envelope varies between A(1 - m) and A(1 + m).
For the spectrum, a plain fft + fftfreq pair gives you the frequency bins. Pick the bins closest to the carrier and the two sidebands (f_c ± f_m) and square the magnitudes to get power.
The modulation index m controls how deeply the message modulates the carrier. Below m = 1.0 the envelope stays positive; at m = 1.0 it just touches zero; above m = 1.0 the envelope flips sign and you get phase reversals that most receivers won’t demodulate cleanly. The function below plots several indices side by side, showing both the time-domain envelope and the frequency spectrum.
1def analyze_modulation_depth_examples():
2 modulation_indices = [0.25, 0.5, 0.75, 1.0, 1.25]
3 carrier_freq, message_freq = 1000, 100
4
5 fig, axes = plt.subplots(len(modulation_indices), 2, figsize=(15, 12))
6 fig.suptitle('AM Signal Analysis: Different Modulation Indices', fontsize=16)
7
8 for i, mi in enumerate(modulation_indices):
9 am_signal, _, _ = analyzer.generate_am_signal(carrier_freq, message_freq, mi)
10 calculated_mi, envelope = analyzer.calculate_modulation_index(am_signal)
11
12 # Time domain
13 axes[i, 0].plot(analyzer.time[:1000], am_signal[:1000], 'b-', label=f'AM Signal (MI={mi:.2f})')
14 axes[i, 0].plot(analyzer.time[:1000], envelope[:1000], 'r--', label=f'Envelope (Calculated MI={calculated_mi:.3f})')
15 axes[i, 0].set_title(f'Modulation Index: {mi}')
16 axes[i, 0].set_xlabel('Time (s)')
17 axes[i, 0].set_ylabel('Amplitude')
18 axes[i, 0].legend()
19 axes[i, 0].grid(True)
20
21 # Frequency domain (positive frequencies only)
22 sb = analyzer.analyze_sidebands(am_signal, carrier_freq, message_freq)
23 freqs = sb['frequencies']
24 mag = np.abs(sb['fft_signal'])
25 mask = freqs >= 0
26
27 axes[i, 1].plot(freqs[mask], mag[mask], 'g-')
28 axes[i, 1].set_title(f'Frequency Spectrum (MI={mi})')
29 axes[i, 1].set_xlabel('Frequency (Hz)')
30 axes[i, 1].set_ylabel('Magnitude')
31 axes[i, 1].set_xlim(0, 2000)
32 axes[i, 1].grid(True)
33
34 plt.tight_layout()
35 plt.show()
36
37analyze_modulation_depth_examples()
Two things to watch in the output: for m ≤ 1 the red envelope curve stays above zero, and the spectrum shows a clean carrier with two small sidebands. For m > 1 (overmodulation), the envelope crosses zero and the spectrum gains extra harmonic components that aren’t in the original message, visible audio distortion on a real receiver.
In AM, the total transmitted power splits between the carrier and the two sidebands. The carrier carries no information, so the share of power in the sidebands is a direct measure of transmission efficiency. Theory says the sideband power scales with m² / 2 relative to carrier power; this function sweeps the modulation index and plots the result.
1def sideband_power_analysis():
2 carrier_freq, message_freq = 1000, 100
3 modulation_indices = np.linspace(0.1, 1.5, 20)
4
5 carrier_powers = []
6 sideband_powers = []
7 total_powers = []
8
9 for mi in modulation_indices:
10 am_signal, _, _ = analyzer.generate_am_signal(carrier_freq, message_freq, mi)
11 a = analyzer.analyze_sidebands(am_signal, carrier_freq, message_freq)
12
13 carrier_powers.append(a['carrier_power'])
14 sideband_powers.append(a['upper_sideband_power'] + a['lower_sideband_power'])
15 total_powers.append(a['total_power'])
16
17 fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(14, 5))
18
19 # Power vs modulation index
20 ax1.plot(modulation_indices, carrier_powers, 'b-', label='Carrier', linewidth=2)
21 ax1.plot(modulation_indices, sideband_powers, 'r-', label='Sidebands', linewidth=2)
22 ax1.plot(modulation_indices, total_powers, 'g-', label='Total', linewidth=2)
23 ax1.set_xlabel('Modulation Index')
24 ax1.set_ylabel('Power')
25 ax1.set_title('Power Distribution vs Modulation Index')
26 ax1.legend()
27 ax1.grid(True)
28
29 # Sideband efficiency
30 efficiency = np.array(sideband_powers) / np.array(total_powers) * 100
31 ax2.plot(modulation_indices, efficiency, color='purple', linewidth=2)
32 ax2.set_xlabel('Modulation Index')
33 ax2.set_ylabel('Sideband Efficiency (%)')
34 ax2.set_title('Share of Total Power in Sidebands')
35 ax2.grid(True)
36
37 plt.tight_layout()
38 plt.show()
39
40sideband_power_analysis()
The efficiency curve confirms the textbook result: at m = 1.0, only about 33% of the total power is in the sidebands (the useful part); the rest is in the carrier. That’s why commercial AM systems sometimes use variants (SSB, DSB-SC) that suppress part or all of the carrier.
With numpy, scipy.signal, and scipy.fft, the two core measurements for an AM signal, modulation index from the envelope and power distribution via FFT, take only a few dozen lines. The same envelope-detection and spectrum-inspection approach also works for demodulating real recordings, for example from an SDR capture, as long as you resample down to a manageable rate first.
For the starting point, see AM Wave Generation and Plotting.