Site menu FM modulation
e-mail icon
Site menu

FM modulation

e-mail icon

FM (Frequency Modulation) is the most common audio analog modulation in use, either in commercial stations, ham radio and walkie-talkies. (A big exception is airband, which is AM).

Even the digital radio modes like DMR and D-Star are FM-based to some extent, which allows for cheaper circuits and pacific coexistence of analog and digital modes in the band.

In this article, I try to show how a baseband signal is transformed under FM modulation using a Python implementation.

The baseband is a filtered audio with my voice, already employed in the article about AM modulation:


(link to audio)

Now, it is FM-modulated with a 10kHz carrier. The bandwidth of the baseband is limited to 1kHz, and the maximum frequency deviation was also limited to 1kHz. Please don't listen the result in high volume, it could make your head explode!


(link to audio)

It seems impossible to recover voice from this mess, but indeed it can be recovered. There is some loss in quality due to the filters at the "receptor":


(link to audio)

Now, let's see how this is carried out. First, a little bit of theory. FM modulation is based on tweaking the carrier frequency, based on input signal strength.

The modulated output has constant power envelope, even when the signal is pure silence. This makes FM very noise-resistant. Is also allows for a simpler and more efficient transmitter.

In AM, and even more in SSB, the output power has a linear relationship with the input. This lets the signal vulnerable to noise during low-power stints. The transmitter must be more complex and less efficient, because it must linearly modulate the output.

An analog FM transmitter may be incredibly simple. Using a varicap oscillator (varicap is a diode that works as a voltage-controlled variable capacitor), the input can directly control the varicap, and the oscillator frequency.

Digital realization is a little more tricky. One technique is to rush or drag the carrier's phase accordingly to the input. Continuously "rotating" the carrier's phase is the same as deviating the frequency.

The crucial step is to relate the input with phase deviation, so the output frequency does not go beyond the bandwidth (in the examples, it is 9kHz-11kHz). The input signal must be integrated, since the phase deviation is left as it is, rather than reset, when the input is silenced.

Instead of tweaking the carrier phase directly, we translate the phase to cartesian coordinates (I/Q) and then use a quadrature modulator. This is the bread-and-butter technique for software-defined radios.

#!/usr/bin/env python3

# FM modulator based on I/Q (quadrature) modulation

import wave, struct, math

input_src = wave.open("paralelepipedo_lopass.wav", "r")
FM_CARRIER = 10000.0
MAX_DEVIATION = 1000.0 # Hz

fm = wave.open("iq_fm.wav", "w")
fm.setnchannels(1)
fm.setsampwidth(2)
fm.setframerate(44100)

phase = 0 # in radians
for n in range(0, input_src.getnframes()):
        # rush or drag phase accordingly to input signal
        # this is analog to integrating
        inputsgn = struct.unpack('h',
		input_src.readframes(1))[0] / 32768.0

        # translate input into a phase change that changes frequency
        # up to MAX_DEVIATION Hz 
        phase += inputsgn * math.pi * MAX_DEVIATION / 44100
        phase %= 2 * math.pi

        # calculate quadrature I/Q
        i = math.cos(phase)
        q = math.sin(phase)

        carrier = 2 * math.pi * FM_CARRIER * (n / 44100.0)
        output = i * math.cos(carrier) - q * math.sin(carrier)

        fm.writeframes(struct.pack('h', int(output * 32767)))

At receiving side, FM is much more complicated than AM. There are no FM "crystal sets". Even the DIY kits employ some ASIC like TDA7000. (Commercial FM has the additional complication of stereo audio, conveniently sorted out by the TDA7000 as well.)

There are many analog and digital methods to demodulate FM. In our implementation, we use a bread-and-butter digital recipe. A quadrature demodulator extracts the I/Q values, which are converted to polar angle, and the phase rotation is monitored to extract the original audio.

An important aspect is the low-pass filtration of I/Q intermediate signal. As mentioned in AM modulation text, any demodulation creates high-frequency copies of the signal. This is just a nuisance in AM, but it would prevent signal recovery in FM, so we must filter.

#!/usr/bin/env python3

# FM demodulator based on I/Q (quadrature)

import wave, struct, math, random, filters

input_src = wave.open("iq_fm.wav", "r")
FM_CARRIER = 10000.0
MAX_DEVIATION = 1000.0 # Hz

demod = wave.open("iq_fm_demod.wav", "w")
demod.setnchannels(1)
demod.setsampwidth(2)
demod.setframerate(44100)

# Prove we don't need synchronized carrier oscilators
initial_carrier_phase = random.random() * 2 * math.pi

last_angle = 0.0
istream = []
qstream = []

for n in range(0, input_src.getnframes()):
        inputsgn = struct.unpack('h',
		input_src.readframes(1))[0] / 32768.0

        # I/Q demodulation, not unlike QAM
        carrier = 2 * math.pi * FM_CARRIER * (n / 44100.0)
		+ initial_carrier_phase
        istream.append(inputsgn * math.cos(carrier))
        qstream.append(inputsgn * -math.sin(carrier))

istream = filters.lowpass(istream, 1500) 
qstream = filters.lowpass(qstream, 1500) 

last_output = 0

for n in range(0, len(istream)):
        i = istream[n]
        q = qstream[n]

        # Determine phase (angle) of I/Q pair
        angle = math.atan2(q, i)

        # Change of angle = baseband signal
        # Were you rushing or were you dragging?!
        angle_change = last_angle - angle

        # Just for completeness; big angle changes are not
        # really expected, this is FM, not QAM
        if angle_change > math.pi:
                angle_change -= 2 * math.pi
        elif angle_change < -math.pi:
                angle_change += 2 * math.pi
        last_angle = angle

        # Convert angle change to baseband signal strength
        output = angle_change / (math.pi * MAX_DEVIATION / 44100)
        if abs(output) >= 1:
                # some unexpectedly big angle change happened
                output = last_output
        last_output = output
        
        demod.writeframes(struct.pack('h', int(output * 32767)))

The filter is rather crude FIR filter and was borrowed from this article about FIR filters. It was good enough for the purposes of this article:

#!/usr/bin/env python

import numpy, math
from numpy import fft

SAMPLE_RATE = 44100 # Hz
NYQUIST_RATE = SAMPLE_RATE / 2.0
FFT_LENGTH = 512

def lowpass_coefs(cutoff):
        cutoff /= (NYQUIST_RATE / (FFT_LENGTH / 2.0))

        # create FFT filter mask
        mask = []
        negatives = []
        l = FFT_LENGTH // 2
        for f in range(0, l+1):
                rampdown = 1.0
                if f > cutoff:
                        rampdown = 0
                mask.append(rampdown)
                if f > 0 and f < l:
                        negatives.append(rampdown)

        negatives.reverse()
        mask = mask + negatives

        # Convert FFT filter mask to FIR coefficients
        impulse_response = fft.ifft(mask).real.tolist()

        # swap left and right sides
        left = impulse_response[:FFT_LENGTH // 2]
        right = impulse_response[FFT_LENGTH // 2:]
        impulse_response = right + left

        b = FFT_LENGTH // 2
        # apply triangular window function
        for n in range(0, b):
                    impulse_response[n] *= (n + 0.0) / b
        for n in range(b + 1, FFT_LENGTH):
                    impulse_response[n] *= (FFT_LENGTH - n + 0.0) / b

        return impulse_response

def lowpass(original, cutoff):
        coefs = lowpass_coefs(cutoff)
        return numpy.convolve(original, coefs)

if __name__ == "__main__":
        import wave, struct
        original = wave.open("NubiaCantaDalva.wav", "r")
        filtered = wave.open("test_fir.wav", "w")
        filtered.setnchannels(1)
        filtered.setsampwidth(2)
        filtered.setframerate(SAMPLE_RATE)

        n = original.getnframes()
        original = struct.unpack('%dh' % n, original.readframes(n))
        original = [s / 2.0**15 for s in original]

        result = lowpass(original, 1000)
        
        result = [ int(sample * 2.0**15) for sample in result ]
        filtered.writeframes(struct.pack('%dh' % len(result),
		*result))

Given that FM can be realized by rotating the phase of a carrier, we can understand FM as the analog version of phase modulation (PM), which is very common in the digital realm. Indeed, digital modes based on phase or frequency shift (PM, FSK, MSK, GMSK...) can all be realized through FM modulation/demodulation of a digital signal (square wave).

Naturally, real-world radios use better techniques, but using FM as front-end for digital receiving is good enough to listen DMR and D-Star signals easily and cheaply, using SDR and DSD.

e-mail icon