Receiving analog video over FM

An FM receiver recovers a signal that was encoded in the changes in frequency of a carrier. This page builds up to decoding 5.8 GHz analog video with an ESP32-C5 microcontroller, one piece at a time.

FM modulation and demodulation

The carrier

A carrier is a pure sine wave at one fixed frequency. On its own it carries no information, because every cycle looks like the last.

\class{c-carrier}{c(t) = A \sin(2\pi f_c t)}

The arrow on the right is the same wave drawn as a rotating vector, called a phasor. Its length is the amplitude and it turns one full circle per cycle. The height of its tip, traced over time, draws the wave. I/Q sampling measures this arrow directly, so it will come back.

One second of signal, played at one-tenth speed. Click the plot to pause.

The signal

The signal is the information to send: audio for broadcast radio, or composite video for an analog FPV link. Here it is a single slow tone.

Its frequency is far below the carrier's. That separation is what lets the two be combined for transmission and pulled apart again at the receiver.

The gauge on the right is the signal's value right now, at the leading edge of the trace.

Frequency modulation

FM lets the signal steer the carrier's frequency. When the signal is high the carrier speeds up, and when it is low the carrier slows down. The amplitude never changes, so all of the information is in timing.

\class{c-modulated}{f_{\text{inst}}(t)} = \class{c-carrier}{f_c} + \class{c-modulated}{\Delta f} \cdot \class{c-message}{m(t)}

\Delta f is the peak deviation: how far the frequency swings at the signal's extremes. Frequency is the rate of change of phase, so the phase is the running total of the frequency:

\class{c-modulated}{s(t)} = \sin\phi(t), \qquad \phi(t) = 2\pi \class{c-carrier}{f_c} t + 2\pi \Delta f \int_0^t \class{c-message}{m(\tau)}\, d\tau

For a single tone \class{c-message}{m(t) = \sin(2\pi f_m t)}, the integral works out to a cosine, up to a constant:

\phi(t) = 2\pi \class{c-carrier}{f_c} t - \beta \cos(2\pi \class{c-message}{f_m} t), \qquad \beta = \frac{\Delta f}{\class{c-message}{f_m}}

\beta is the modulation index. It is also the largest angle, in radians, that the FM signal's phase gets ahead of or behind an unmodulated carrier. Turn on the reference carrier to watch that lead and lag.

The arrow stays the same length but speeds up and slows down with the signal. Demodulation is the job of measuring that rotation speed.

Push Δf past the carrier frequency and the arrow reverses. That is a negative frequency, and telling the two directions apart is what I and Q are for.

Mixing with a local oscillator

The FM signal's information is in its phase, but the receiver can't read phase from a single wave with nothing to compare it to. So it makes its own sine wave near the carrier frequency, called the local oscillator (LO), and multiplies the incoming signal by it. Multiplying two signals is called mixing, and the circuit that does it is a mixer.

The product of two sine waves is two new waves, one at the difference of their frequencies and one at their sum:

2 \sin A \sin B = \underbrace{\cos(A - B)}_{\text{slow part}} - \underbrace{\cos(A + B)}_{\text{fast part}}

With the signal near the LO frequency, the difference is small and the sum is large, around twice the carrier. The slow part is the useful one: it depends only on how the signal's phase compares with the LO's.

Set both frequencies to the same value and the slow part stops moving. It becomes a constant level, called DC (after direct current), equal to the cosine of the phase difference. It reads 1 when the two waves are in step, 0 when they are a quarter cycle apart, and −1 when they are opposite. Drag the phase slider to see it.

The bottom trace is the product. The bold line is its slow part, and the rest of the wiggle is the fast part riding on top.

Low-pass filtering

To keep the slow part and throw away the fast part, the receiver uses a low-pass filter (LPF). A low-pass filter lets low frequencies through and blocks high ones.

The simplest low-pass filter is a moving average: each output is the average of the input over the last T seconds.

\class{c-demod}{y(t)} = \frac{1}{T} \int_{t-T}^{t} x(\tau)\, d\tau

A fast wiggle averages to nearly zero, because its ups and downs cancel inside the window. A slow wave barely changes across the window, so its average is close to its current value.

How much of each frequency survives depends on the window length, and the panel on the right plots that gain against frequency. A longer window blocks more of the fast part, but it also shrinks the slow part and delays it, because the average lags half a window behind. The fast part cancels completely whenever the window holds a whole number of its cycles, which is where the gain curve touches zero.

The shaded box at the right edge is the averaging window. The bold trace is the filter's output, the average of the thin input trace inside the box.

Two mixers: I and Q

One mixer and a filter give the cosine of the phase difference. That isn't enough: the cosine of +45° and −45° are equal, so the output can't tell whether the signal is ahead of the LO or behind it.

The fix is a second mixer whose LO is shifted a quarter cycle ahead. The first mixer's filtered output is called I, for in-phase, and the second's is Q, for quadrature (a quarter turn apart).

\class{c-i}{I(t)} = \operatorname{LPF}\left[2\,\class{c-modulated}{s(t)} \sin(2\pi f_{LO} t)\right], \qquad \class{c-q}{Q(t)} = \operatorname{LPF}\left[2\,\class{c-modulated}{s(t)} \cos(2\pi f_{LO} t)\right]

I is the cosine of the phase difference and Q is its sine, so together they are the coordinates of the FM signal's phasor, drawn on a plane called the IQ plane. It is the same arrow as before, but seen from a viewpoint that rotates along with the LO:

\class{c-i}{I} + j\,\class{c-q}{Q} = e^{j\theta(t)}, \qquad \theta(t) = \phi(t) - 2\pi f_{LO} t

When the LO sits exactly on the carrier, the carrier's rotation cancels out and the arrow only swings back and forth by \pm\beta. A tuning error adds a slow, steady spin on top of the swing.

Two numbers also settle the direction of rotation. With I alone, an arrow turning counterclockwise looks the same as one turning clockwise, which is the difference between a signal above the LO and one below it. Q tells them apart.

Faint traces are the mixer outputs and solid traces are after the moving-average filter. The trail on the IQ plane is the arrow's last half second. Shorten the window to see leftover fast ripple wobble the arrow.

Reading the phase

The IQ plane turns demodulation into geometry. The signal is in how fast the arrow turns, so the first step is to find which way the arrow points: its angle \class{c-modulated}{\theta}, measured counterclockwise from the positive I axis.

\class{c-modulated}{\theta} = \operatorname{atan2}(\class{c-q}{Q}, \class{c-i}{I})

atan2 is the arctangent extended to all four quadrants. The plain arctangent of Q/I can't tell the point (1, 1) from (−1, −1), because the ratio is the same, so atan2 also looks at the signs of I and Q. It returns an angle between −180° and +180°.

That range has a catch. When the arrow turns past 180°, the angle jumps to −180°, even though the arrow barely moved. This is called wrapping. Set a tuning error so the arrow keeps spinning, and the phase trace becomes a sawtooth of these jumps.

The shaded wedge on the IQ plane is the angle θ, and the trace on the left is θ over time.

Frequency is the slope of the phase

Frequency is how fast phase changes, so the instantaneous frequency is the slope of \theta over time. For now, set the wrapping aside and look at the phase as one continuous curve, which is called unwrapped phase.

\class{c-demod}{f(t)} = \frac{1}{2\pi} \frac{d\theta}{dt} = \Delta f \cdot \class{c-message}{m(t)}

The phase curve is steepest where the signal is at its peaks, and flat where the signal crosses zero. With the LO exactly on the carrier, the slope is the signal scaled by \Delta f, so measuring the slope gives the signal back. That is FM demodulation.

The green line on the phase is its tangent at the right edge. Its slope, plotted below in green, has the same shape as the signal at the bottom, scaled by Δf. Raise the deviation to see the phase swing wider and the slope grow while the signal stays the same.

Measuring between samples

A digital receiver doesn't have a continuous \theta(t). It has samples of I and Q taken f_s times per second, called the sample rate. So it approximates the slope with the change in angle from one sample to the next:

\begin{aligned} \class{c-modulated}{\theta[n]} &= \operatorname{atan2}(\class{c-q}{Q[n]}, \class{c-i}{I[n]}) \\ \class{c-demod}{\Delta\theta[n]} &= \operatorname{wrap}\left(\theta[n] - \theta[n-1]\right) \\ \class{c-demod}{\hat f[n]} &= \frac{f_s}{2\pi}\, \Delta\theta[n] \end{aligned}

Here n numbers the samples and \hat f is the estimated frequency. This is called a phase-difference discriminator.

The wrap step deals with the jumps from before. If the difference comes out as +350°, the arrow really turned −10°, so wrap adds or subtracts 360° to bring every difference into the range ±180°.

That same step sets a speed limit. A turn of +200° between samples looks exactly like a turn of −160°, so the arrow must turn less than half a circle per sample. Any frequency beyond \pm f_s/2 folds back inside that range, which is called aliasing. Lower the sample rate or raise \Delta f and watch the green samples leave the orange curve at the dashed limits.

A tuning error \delta adds -\delta to every sample, so it shifts the output up or down without changing its shape. A steady shift like this is called a DC offset. One way to remove it is to measure it and subtract it: average the output over a window long enough to hold several signal cycles, where the signal itself averages to zero, and what remains is the offset. Subtracting a slow average like this is a high-pass filter, the opposite of the low-pass filter from before. Set a tuning error and turn on "Remove DC offset" to see the yellow estimate find the offset and the output recenter.

This has two limits. First, it only works when the signal averages to zero. A tone does, but composite video doesn't, because its brightness levels carry meaning, so video receivers instead line the output up against a known reference level in the signal. Second, subtracting the offset doesn't restore headroom. The offset pushes the output toward one of the \pm f_s/2 limits, so aliasing starts at a smaller \Delta f, and the folding has already happened by the time the offset is subtracted. The dashed limits move with the subtraction to show this. Fixing that means retuning the LO itself, which is called automatic frequency control (AFC).

Noise pushes each sample off the circle. Only the angle matters, so the error depends on how large the noise is compared with the arrow's length. Raise the noise to see the samples scatter. Averaging neighboring samples, which is the same moving-average low-pass filter as before, smooths them back out. The cost is detail and delay: the average lags by half its length, and it also flattens fast changes in the signal.

Dots on the IQ plane are the latest samples. The green wedge is Δθ between the previous sample (grey arrow) and the newest one. Below, each green stem is one sample's frequency estimate, over the true frequency in orange.

CVBS and NTSC video

Composite video

The signal carried by a 5.8 GHz analog video link is the video itself. An analog camera sends its picture as one voltage that varies over time, called composite video or CVBS (color, video, blanking and sync). It is "composite" because brightness, color and timing all share that one wire. This page uses NTSC, the standard used in North America and Japan.

The picture is sent one horizontal line at a time, left to right, top to bottom. Each line lasts 63.6 µs, so there are about 15,734 lines per second. A full frame is 525 lines, about 30 frames per second.

Within each line, the voltage means different things at different times. Levels are measured in IRE units, where 0 is the blanking level and 100 is white. The whole range from −40 to 100 IRE spans 1 V.

  • Horizontal sync (4.7 µs) drops to −40 IRE, the sync tip. Nothing in the picture goes that low, so the receiver can always find it. It marks the start of a new line.
  • The back porch (4.7 µs) sits at blanking level after sync. It gives the receiver a known reference level, and later holds the color burst.
  • Active video (52.7 µs) is the picture itself. Higher voltage is brighter, from black at 7.5 IRE to white at 100 IRE.
  • The front porch (1.5 µs) returns to blanking level before the next sync, so the end of the picture can't blur into the sync edge.

Sync, the porches and anything between black and blanking all show as black on screen. The gap between blanking (0) and black (7.5) is called setup or pedestal. It is specific to North American NTSC; Japanese NTSC and PAL put black at blanking.

This whole waveform is the signal m(t) from the start of the page. An FM video transmitter swings its frequency with this voltage, so the sync tip sits at one end of the deviation and white at the other, and the demodulator's output should reproduce it.

One line of NTSC video, from the end of the previous line to the start of the next. The strip below shows what the line looks like on screen. Toggle the staircase to see six grey levels in one line.

Scanning a picture

A receiver turns the waveform back into a picture by sweeping a spot across the screen in step with it. The spot's brightness follows the voltage during active video. Each sync pulse sends it back to the left edge of the next line, and the blanking around sync covers the time that return takes.

The figure below does this with a small greyscale picture. The magenta spot on the picture and the magenta cursor on the waveform are the same moment in time. The panel on the right spans a whole line of time, so the spot spends part of each line in the blanking strips either side of the picture.

The figure simplifies a real frame. It has 60 lines instead of 525, it runs millions of times slower, and it jumps straight from the bottom line back to the top. A real signal pauses at the bottom of each picture, which the next section covers.

A 60-line picture scanned one line at a time. Raise the scan speed to watch whole frames build up. The grey scale along the bottom rows shows up as a staircase in the waveform.

Vertical blanking

At the bottom of each picture the spot has to return to the top, which takes much longer than returning to the start of a line. The signal spends about 20 lines on this, called the vertical blanking interval, and nothing in those lines is drawn.

The receiver needs a way to tell this apart from an ordinary line. That is the vertical sync pulse: instead of a 4.7 µs dip, the signal stays at the sync tip for most of three lines. A receiver that smooths the signal sees a much longer stretch of sync and knows to return to the top. The sync is cut into six broad pulses by short gaps, called serrations, so horizontal timing keeps going through it.

Either side of vertical sync are three lines of equalizing pulses, narrow pulses at twice the line rate. They exist because of interlacing. A 525-line frame is sent as two fields of 262.5 lines each, the first carrying the odd lines and the second the even lines. That half line shifts the second field's lines down so they land between the first field's. Toggle the second field to see its horizontal sync start half a line later relative to vertical sync. The equalizing pulses make the interval look the same to the vertical sync detector either way, so both fields start at the same height.

After vertical sync come blank lines with ordinary horizontal sync, which give the spot time to settle before the picture resumes around line 21. Each field takes 1/60 s (59.94 Hz exactly), so the screen is refreshed 60 times a second while the full picture updates 30 times.

Above, about 27 lines of signal around the start of a field, with the shaded stretch magnified below. Each dip to the sync tip is a sync pulse. With the second field on, the previous picture ends on a half line.

The color subcarrier

Color was added to NTSC in 1953 without breaking the black-and-white sets already in use. The brightness signal stays exactly as before, now called luma or Y. Color rides on top of it as a fast sine wave, the color subcarrier, at 3.579545 MHz. The subcarrier's amplitude and phase carry the color, and together they're called chroma. A black-and-white set shows the subcarrier as a fine pattern of dots, too small to notice from a distance.

Reading a phase needs a reference, so each line carries one. On the back porch, right after sync, the transmitter sends about nine cycles of the subcarrier at a fixed phase, called the color burst. The receiver locks its own oscillator to the burst and holds it through the rest of the line. A receiver that finds no burst shows the picture in black and white.

The figure shows one line of color bars, a standard test signal. At the full-line scale the subcarrier's 227.5 cycles per line blur into a band, so the lower lane magnifies a 3 µs window. Move it to see the burst, and then how each bar has its own amplitude and phase.

Above, one line of 75% color bars, with the subcarrier drawn as the band it fills. Below, the magnified window, showing individual cycles of burst and chroma.

Hue and saturation

Chroma is built from two color-difference signals, \class{c-i}{U} (blue minus luma) and \class{c-q}{V} (red minus luma), each on its own copy of the subcarrier, a quarter cycle apart:

\begin{aligned} \class{c-i}{U} &= 0.492\,(B - Y) \qquad \class{c-q}{V} = 0.877\,(R - Y) \\ \class{c-modulated}{C(t)} &= \class{c-i}{U} \sin(2\pi f_{sc} t) + \class{c-q}{V} \cos(2\pi f_{sc} t) \end{aligned}

This is the same form as the I/Q signals from the FM section: two amplitudes on quadrature carriers. The receiver separates them the same way, by multiplying the chroma by the sine and cosine of its burst-locked reference and low-pass filtering each product. With Y, U and V it can solve for red, green and blue.

Drawn as a phasor, chroma's angle is the hue and its length is the saturation. A grey pixel has no chroma at all. A plot of this plane is called a vectorscope, and its boxes mark where each standard color bar should land. The burst sits at 180°, pointing along -U.

If the receiver's reference slips relative to the burst, every hue rotates by the same angle, just as a tuning error spun the FM phasor. Set a reference phase error to see the decoded color shift. Transmission paths that shifted the subcarrier's phase caused exactly this, which is why NTSC sets had a tint control.

The NTSC standard itself uses a pair of axes named I and Q, rotated 33° from U and V. They're the same idea at a different angle; this page uses U and V to keep them apart from the radio's I and Q.

The composite signal and its chroma, slowed about seven million times. The yellow reference is the receiver's oscillator locked to the burst; the offset between its peaks and the chroma's is the hue. Defaults match the red bar.

A color picture

Putting it together, a color line carries three things at once: sync and blanking for timing, luma for brightness, and chroma riding on the luma for color. A black-and-white set reads only the luma line through the middle of the band and ignores the rest.

The figure scans the earlier picture in color, with color bars in place of the grey scale. The shaded band on the waveform is the chroma: its height is the saturation, and its phase, too fast to draw here, is the hue. Burst appears on every line's back porch except during vertical sync. The top rows of the panel are a shortened vertical blanking interval, a few lines of broad sync pulses and then blank lines.

A color picture scanned one line at a time, with the vertical blanking interval above it and horizontal blanking either side. On the waveform, the orange line is luma and the purple band is chroma. Turn off color to see the signal a black-and-white camera would send.

Demodulating with the ESP32-C5

The C5VRX project uses the ESP32-C5's 5 GHz Wi-Fi radio to receive analog FM video, and the chip's onboard peripherals to demodulate it into analog video for goggles, a monitor or another device.

The ESP32-C5 is a Wi-Fi microcontroller, and its radio covers the 5 GHz band, which reaches the 5.8 GHz video channels. Its front end already does the mixing from the FM section: it tunes a local oscillator, mixes the antenna signal down to baseband, and digitizes I and Q for the Wi-Fi modem.

The modem's debug bus

The Wi-Fi modem can't demodulate analog FM, but the chip has a diagnostic bus, MODEM_DIAG, that can route signals from inside the modem to other peripherals. The modem samples I and Q about 80 million times a second, 10 bits each. C5VRX routes the top four bits of I and the top four of Q onto the bus, one byte per sample. The parallel I/O peripheral (PARLIO) captures every second sample, 40 million a second, straight into a ring buffer in memory.

The radio's own path, then the peripherals C5VRX chains together, with the resistor ladder outside the chip. Below, how one 40 MS/s sample is packed.

Quantized I/Q

Four bits gives each of I and Q only 16 possible values, so every sample lands on a 16 by 16 grid. The phasor's circle becomes a ring of grid points, and the angle of each sample is the angle of the nearest point rather than the true one.

A weak signal makes a small circle that crosses only a few grid points, and near the center the angle between neighboring points jumps by large steps. That is where most of the picture's static comes from.

Shrink the amplitude to see the ring of points collapse into a few coarse steps. More bits make the grid finer, which is what the P4 part later takes advantage of.

The grey arrow is the true phasor and the purple arrow the grid point it snaps to, with recent points trailing behind. Above left, the angle computed from the snapped point, stepping around the true angle; below, the difference between them.

Keeping the circle the right size

On a 16 by 16 grid the size of the circle decides the picture. A circle that is too small lands on a few cells and the angle jumps. One that is too big piles samples onto the outer ring of cells, the rail, and the circle flattens. The radio's gain sets the size, and C5VRX has to set it itself: the vendor packet AGC hunts on continuous analog FM, so the firmware turns it off and forces one entry of the PHY's gain table, which has up to 90 entries. The controller uses entries 20 and up.

An entry is three settings: an RF stage at the front end, which sets the noise figure, a baseband code, and one of six fine steps within a baseband bank. A fine step is a small change. A baseband or RF change is a bigger one, and shows in the picture while the radio settles, so the controller prefers fine steps, then baseband, then RF.

It never reads the radio's signal strength. Every 200 µs it takes 256 samples from the newest completed receive buffer, four separate 64-byte regions, and measures them on the same 16 by 16 grid the demodulator sees. Each sample's power is about r^2 + 1 in grid steps, and the controller keeps the median (P50), the 90th and 95th percentiles, the share of samples near the center, the share on the rail, and coherence: the share of samples that are strong enough to have a reliable angle and whose phase moved no more than 45° from the one before, which a carrier does and noise does not.

Median power between 13 and 32, with P95 at most 65 and few samples on the rail or near the center, is the healthy band. That is a circle of radius about 3.5 to 5.5 steps out of 8. Move the level in the figure to see the measurements, and the action each would cause.

One window of 256 samples from a carrier turning about a tenth of a turn a sample, plus noise, on the 16 by 16 grid. The histogram counts each sample's power, with the P50 and P95 marked and the healthy band for P50 shaded in green. The verdict follows the branches of the controller in the order it takes them, and the window refreshes a few times a second.

In the healthy band the controller makes no gain writes at all, since each write disturbs the picture. Outside it, it acts on the first window rather than waiting. It asks for the whole correction at once: it aims for a power of 27 when the signal is too strong and 17 when too weak, converts the difference to decibels, and picks the gain entry whose predicted P50 and P95 land in the band. Predictions come from the response it has learned for each entry, and for an entry it has never measured it trusts only a fine step, about 0.8 dB. A move has to pass a margin first, 0.35 dB for a fine step, 0.75 dB for baseband and 1.5 dB for RF, so a level sitting on the edge of the band does not make it jump back and forth.

After a write it waits for the gain to settle, at least 0.3 ms or three quarters of the time it learned for that kind of move, then checks that the measurements are steady before it trusts them. It uses the result to learn how that entry really changes the power, and scores any drop in coherence or rise in clipping as an artifact. An entry that keeps coming out bad, with 10% of samples on the rail, half near the center, or coherence under 30%, is skipped.

Overload does not wait. With 10% of samples on the rail, or a P95 of 95 or more, the controller drops at once: baseband first, and the RF stage if the next window is still saturated, since baseband overdrive and front-end compression look the same from the rail alone. A second task, woken by the same 200 µs timer, looks at one 64-byte window and calls the controller early if it sees the rail.

Two rules keep it from hunting. Two reversals of direction within 20 ms make it wait for 8 windows, about 1.6 ms, for the next 200 ms, though saturation is never delayed. And with no carrier at all it listens at the table's maximum gain rather than a safe middle one, because a weak carrier at the middle gain looks like no carrier.

Once the gain is at its maximum and the circle is still too small, the controller changes which bits it reads rather than adding gain. The modem's samples are 10 bits, and the C5 sends four of them: the sign and three below it. Dropping one of the bits just under the sign leaves the same angle with the circle twice as large, 6 dB more. The first of these lanes keeps bits 9, 7, 6 and 5, and the second bits 9, 6, 5 and 4, for 12 dB. Switching is a remap of the output pins, so it is instant and exact. Outside its window a lane folds, so a signal that rises past it shows up as rail samples or incoherent junk, and the controller goes back to the coarse lane at once. The finest lane is also capped where the receiver's own noise reaches about a step, because finer would only resolve noise.

As a last resort for a weak carrier at the cap of the finest lane, an automatic mode halves the radio's bandwidth from 40 MHz to 20 MHz after a second. That cuts the noise by about 3 dB and trims fine detail and color, and it goes back after a second of clear signal.

Output codes

Everything C5VRX computes ends as an output code: a 6-bit number from 0 to 63, sent 20 million times a second. Six GPIO pins carry the six bits to a resistor ladder that turns each code into a voltage, covered at the end of this part. With 0 as the sync tip and 63 as white, blanking lands near 18 and black near 21, which leaves about 40 steps for everything from black to white.

Twenty million codes a second is enough for the whole video signal, not just the brightness. A sampled signal can represent frequencies up to half its sample rate, here 10 MHz. NTSC luma extends to about 4.2 MHz, and chroma sits around the 3.58 MHz subcarrier with sidebands reaching about the same limit, so both fit with room to spare. Each subcarrier cycle gets about 5.6 samples, enough to keep the burst's phase and every bar's hue.

Six bits is about as far as a resistor ladder goes. The ladder uses one resistor per bit, each about half the one before, and with standard resistor values each code already lands up to half a step off a straight line. A seventh bit would halve the step, and the error would be as large as the step itself.

A smooth change in brightness has to jump from one step to the next, so a gradient turns into visible bands. Reduce the bits to see the bands widen; each bit removed halves the number of steps.

One line carrying a black-to-white ramp. The green trace is the nearest output code, and the strips below compare the exact ramp with what the codes show on screen.

Phase from a lookup table

At 40 million samples a second there's no time for atan2. Every 8-bit sample has only 256 possible values, so the angle of each one can be computed ahead of time and stored in a table. C5VRX stores it as a 5-bit phase, 32 steps of 11.25° numbered counterclockwise from the I axis.

Phase change between samples

The output runs at 20 million codes a second, half the capture rate, so C5VRX keeps every second sample and measures how far the phase moved across two sample periods, 50 ns. This is the phase-difference discriminator from the FM part, with f_s at 20 MHz.

Instead of subtracting, it uses a second table, indexed by the previous and current phase, that returns the output code directly. There are only 32 × 32 = 1024 pairs, so the rest of the work can be computed ahead of time and stored in the table: wrapping the difference, scaling it to a frequency, and turning that into a video level. The next three sections take these one at a time.

Wrapping the difference

The phases go around a circle, so a sample moving from phase 30 to phase 1 has turned 3 steps forward, not 29 back. As with the wrap in the FM part, the table treats every difference as the shortest way around, a step between −16 and +15.

That sets the same speed limit: less than half a turn in 50 ns, or ±10 MHz. A step of +20 looks exactly like a step of −12, so the table can't tell them apart.

Steps to frequency

Each step is 1/32 of a turn in 50 ns. From the discriminator formula that's f_s/32, 625 kHz, so the step count is the signal's frequency relative to the tuned frequency, in units of 625 kHz.

Brightness is carried as frequency, so the table multiplies the step by a gain: each step adds about six to the output code. With the receiver tuned to the blanking level, sync sits about three steps below it, about 1.9 MHz, and white about seven above, about 4.4 MHz.

Offset and clamp

A step of zero means the signal is at the tuned frequency, which is blanking, so the table adds an offset that puts zero steps at code 20. That's a couple of codes above the 18 calculated from the output levels.

Codes stop at 0 and 63, so steps past sync clamp to 0 and steps past white clamp to 63. A little further out, 9 to 11 steps either way, the table eases back toward blanking.

A step of 12 or more either way is too far to trust, since it could be the wrap from the other direction, so the table sends it to blanking. Noise near the center of the I/Q plane makes large random steps, and sending those to sync instead would make false sync pulses that tear the picture.

The figure shows one row of the table. Set the previous and current phase to see the wrapped step between them, and where that step lands among the output codes.

Left, the previous phase in grey and the current phase in purple, with the wedge between them taking the shortest way around. Right, the output code for every step from the previous phase: scaled around blanking, clamped at sync and white, easing back, then sent to blanking where the step is too far to trust.

Both tables at work

The ESP32-C5's BitScrambler, a small programmable unit that rewrites a DMA stream as it passes, does both lookups for every sample without the CPU.

The figure feeds a line of grey staircase video through the real tables from C5VRX. Lower the amplitude or add noise to see the phase jitter between neighboring cells, and the output code jitter with it.

The input is a synthesized FM signal carrying one line of grey staircase video, slowed so each output takes a fraction of a second. Above, the output codes as they're produced. Below left, the 256-entry phase table laid out as the I/Q grid, colored by phase: the arrow points to the current sample, with recent kept samples trailing behind, the previous one outlined and the skipped one between them in grey. During blanking the arrow stands still, because blanking is the frequency the receiver is tuned to; brighter parts of the line turn it faster. Below right, the 1024-entry output table, brightness showing the code, with the current pair marked.

A resistor ladder

Six GPIO pins carry the six bits of each code, and each pin drives the video output through its own resistor, from 8.2 kΩ for the lowest bit to 240 Ω for the highest. Each resistor is about half the one before, so each bit adds about twice the current of the bit below it, and the voltage at the output follows the code.

The voltage comes from three sums. First, a pin that is high pushes a current into the output, 3.3\,\text{V} \div R, as it would if the output were held at ground. A pin that is low pushes nothing. Second, every resistor, high or low, also connects the output to ground, so the output sees all of them in parallel, along with a 200 Ω resistor and, when connected, the TV's 75 Ω input. Third, the voltage is the total current times that parallel resistance.

Move the code and watch the sums change. A high pin lights up green and its current joins the total, and a low pin's current drops out while its resistor stays in the parallel group. With all six bits high the output is about 1 V, the full video range. Disconnect the TV and the parallel resistance rises, so the voltage roughly doubles.

The resistors are standard values, so they're only close to doubling. Each code lands up to about 8 mV off a straight line, half the 16 mV between codes.

The ladder for the chosen code, with green dots for the current each high pin pushes, and the sums that give the output voltage for that code. On wider screens, the output voltage for every code against a straight line is below.

Decoding with the ESP32-P4

The C5 alone produces analog video for goggles or a screen. To record it or show it on a computer, the video has to become digital frames instead. A second build pairs the C5 with an ESP32-P4, a chip with two 360 MHz cores, a hardware JPEG encoder and high-speed USB.

The C5 tunes the radio, sets its gain on command and passes its raw I and Q along. The P4 demodulates the FM, decodes NTSC in color and shows up on the computer as a USB webcam at 60 pictures a second. It uses the same ideas as the C5, with finer numbers and a CPU to spare.

A slower sample rate

The C5 keeps every sixth sample of its 80 MS/s bus, 13.33 million a second, where its own tables use 40 million. The speed limit from the FM part is half the sample rate, here ±6.67 MHz. The picture signal has to stay inside it, since a frequency past the limit wraps to the other end.

At 360 MHz the slower rate gives a core 27 clock cycles per sample.

Phase from two small tables

The P4 also has a BitScrambler, and it does the phase lookup there, as the C5 does, but with a table of 1024 entries rather than 256. A lookup indexed by 14 bits would be far too big, so it works in two steps.

First each lane's 7 bits become a 5-bit magnitude code with a sign. The 32 codes are spaced logarithmically, a trick called companding that the next section explains. A flag marks a lane at full scale.

Second, the two magnitude codes address a table of the angle of that point in the first quadrant, 64 steps to a quarter turn. The signs say which quadrant, and in the odd quadrants the two codes swap places so the angle always counts on from the start of the quadrant. The result is a phase byte, 256 steps to a turn, and the BitScrambler needs four cycles for each sample to do it.

It writes a second byte for each sample as well: I's magnitude code and the clipped flags, which the gain control reads in a later section.

Companding

Companding is compressing a value on the way in and expanding it on the way out. Telephone systems use it to fit speech into 8 bits. Here a lane's 7 bits are compressed to a 5-bit code, and the angle table then reads each code as the middle of its range, the expanded value.

The compression is there because the angle table has to be small. Two lanes of 7 bits would index 128 × 128 = 16384 entries, while two lanes of 5-bit codes index 32 × 32 = 1024. The question is where to put the 32 steps in each lane.

A step of size \Delta moves a point on a phasor of length r by an angle of about \Delta / r. So evenly spaced steps are fine for strong samples and coarse for weak ones: a step of 4 units is about a 2° error at r = 100 but about 40° at r = 6. What matters is the step size relative to the sample, which is what log spacing keeps constant.

The P4's codes follow \text{code} = \lfloor s \log_2(1 + |v|/k) \rfloor, where k is the knee, 6 units, and s sets the 32 codes to span 160. Well below the knee the steps are about even and small, under one unit wide. Well above it each step is a fixed share of the value. A pure logarithm would need infinitely small steps at zero, and the knee stops that. Move the knee in the first figure to see the steps change.

Each lane's magnitude, in the 2x+1 units of the P4's lanes, against the code it gets. The grey staircase is 32 evenly spaced codes and the purple one is the P4's log-spaced codes. The ticks along the axis show where each code starts. The log-spaced codes reach 160 so the table can be centered on an offset, and a lane of up to 127 uses 30 of them.

The second figure measures the result. It runs the same phasor through the whole lookup, including the rounding of the angle to 256 steps, and plots the RMS angle error against the signal's amplitude. At 5% of full scale the evenly spaced codes give 10.6° of error and the log-spaced codes 5.0°, which is what all 7 bits of each lane would give. At 2% it is 26° against 11°. The trade goes the other way for strong signals: at half of full scale the evenly spaced codes do a little better, 1.1° against 1.4°, because the log-spaced steps are wider there. Companding spends its precision on weak signals, which are the ones that fade into noise.

RMS angle error over a full turn at each amplitude, on a log scale. Purple is the P4's lookup, grey is the same 32 codes evenly spaced, and green is a hypothetical table that used all 7 bits of each lane. Hover over the plot, or touch it, to move the dashed line and read the errors at that amplitude. The arrow keys work too.

Differences and wraps

One step of the phase byte is 1/256 of a turn in 75 ns, or f_s/256, about 52 kHz, against 625 kHz on the C5. The BitScrambler can't subtract one sample's phase from the previous one's, so a CPU core does it, 16 samples at a time with the P4's vector instructions. An int8 difference wraps on its own, which is the ±180° wrap from the FM part for free. The same code also sums each four differences, which the sync detector uses.

Noise wraps too. A sample that lands half a turn or more from the mean of its neighbors has wrapped past the range, and is moved to the end of the range it wrapped past, so a noise spike no longer turns white to black or sync to white.

Centering the table

The I and Q samples carry a DC offset that grows with the radio's gain, and the phase table is only right if the circle is centered on the middle of the table. On an empty channel the offset read 71, 50, 40, 24 and 20 units at gains 77, 74, 71, 68 and 65. The table is built around an offset, and a table that is 3 units out triples its error.

The P4 finds the offset from signs. A carrier circling its center spends half its time on each side, so the share of negative samples says how far the table's center sits from the circle's: the offset is r \sin(\pi (n - \tfrac12)) for a negative share n, and the radius r follows from the power about the table's center. The BitScrambler's bytes carry no raw I and Q, so the power comes from I's magnitude code alone and the signs from the phase byte.

A carrier circle whose center is off the table's center, with the half on the negative side of I shaded. The share of negative samples gives the offset and the radius back, which the loop then uses. If the table's center is outside the circle, every sample has the same sign, and only the direction is known.

Every 50 ms the control task reads this, smooths it over about four seconds, and rebuilds the table when the offset is two units out. It remembers the offset found at each gain, so a gain step starts from the offset it had before, or from the baseband gain's share of the step for a gain it has not seen.

Rewriting the table means stopping the BitScrambler for about 17 lines, so the rewrite waits for the last picture lines of a field and the next vertical sync sets the line count right again.

Setting the gain

The P4 sets the same gain as the C5 firmware does, one entry of the PHY's gain table. It sends gain N over the serial link to the C5, whose firmware turns off the packet AGC and forces that entry. The P4 never reads the radio's signal strength, and it has no fine, baseband and RF choices of its own. It moves one number, about a decibel a step, from 30 up to 77, the top of the C5's table. Above 77 the indices clip on noise alone.

Its measurement is the radius from the previous section, in the lanes' 2v+1 units with full scale 127. From 32 samples in each DMA node it totals the power and counts the rail. Nodes where more than a quarter of the samples clip are left out, because another transmitter's burst, such as a Wi-Fi station, clips a few nodes whole and says nothing about this carrier's level. If most nodes clip, that is the carrier itself, and the loop treats it as full clipping. A pass needs 4096 samples to count.

The loop aims for an RMS of 54, between 40 and 72, which keeps the table's rounding small: about 20 adds a third to the picture's noise and about 60 adds almost nothing. When the RMS is out of range the step is 20 \log_{10}(54 / \text{RMS}) steps of gain, at most 10 either way, and at least 2 down when it is too high. More than 0.5% of samples clipping steps down by 2, and more than 10% steps down by 8. When the table's center is outside the circle the RMS reads too high, so then it may raise the gain but not lower it.

It does not chase small errors. Each gain step costs a table reload, which costs lines, so a level a few decibels off has to stay off for 6 passes, about 0.3 s, before the gain moves. A level far off does not wait: an RMS under 27 or over 100, or heavy clipping, moves it at once. After any step the next pass is thrown away, since its samples were taken at the old gain, and a step of 10 or more means a signal arrived or left, so the offset is found afresh.

When the decoder starts, a probe captures raw samples and steps the gain by 6 until the circle is in range, a clip share over 2% or an RMS over 80 down and an RMS under 30 up, from 22 to 70 in at most 8 passes. It measures the offset from that capture too, so the first table starts close.

Finding lines and fields

The demodulated signal has no fixed zero, because a tuning error shifts all of it. The P4 uses the reference levels the video signal provides: it averages the sync tips and back porches of the lines it has locked, and scales everything else from those two levels. Until lines lock it takes them from a histogram of the field, and tries a few fixed sync depths in turn.

The sync detector sums four samples at a time, then the last 16 of those sums, about the width of an hsync, and slices that against a level halfway between sync tip and blanking with a little hysteresis. A pulse between 3.4 and 6.4 µs wide is an hsync, and one over 18 µs is a broad pulse of the vertical interval. Bright picture is clamped so it can't hide a pulse's start.

A line clock predicts where each horizontal sync should fall, 847.4 samples after the last. A pulse within 3 µs of the prediction moves the clock by an eighth of the error and its period by 1/256 of it, within 1% of nominal. Anything else is ignored, so the clock coasts through noise. After 30 lines without a hit it takes the next pulse of the right width as the new start.

The broad pulses of vertical sync reset the line count, and where they fall within the line tells the two fields apart. Dark parts of a picture can dip low enough for long enough to look like vertical sync, so a vertical sync only counts within six lines of where the count expects it, unless three fields have passed without one there.

Try losing most of the pulses and adding false ones, which the figure draws as hsync-width pulses. The clock keeps the line start steady where a receiver that restarts on the first pulse it finds wanders.

Each line has a sync pulse that may be lost (dashed outline) and a false pulse from dark picture content (orange). The green band is the 3 µs gate around where the clock expects a pulse, and only a pulse inside it moves the clock, by an eighth of the error. The transmitter's line rate is 0.1% off nominal, which the clock learns. Below, the error in where each method thinks the line starts.

With no sync the line and field counts coast at their last rate, 262.5 lines a field, and every field is still decoded, so a weak signal shows as static and relocks without a flash, as it does on an analog monitor.

Decoding color

At 13.33 million samples a second the color subcarrier is exactly 189/704 of the sample rate. A table of 704 sine and cosine values therefore repeats forever without drifting, and it serves as the receiver's reference oscillator.

The P4 measures the burst's vector against that table on every line, and averages it over lines. Then it multiplies the chroma by the sine and cosine and sums the products in blocks of 8 samples, which is the multiply and low-pass from the hue section, about 0.6 µs a block.

Sharp brightness edges leak into the chroma band and show as colored fringes. The subcarrier has 227.5 cycles per line, so it turns half a cycle from one line to the next. Adding the chroma of two neighboring lines cancels the leak while the color adds up.

FM noise rises with frequency, which makes chroma the noisiest part of the picture, so each block is also smoothed with its neighbors in a 1 2 1 weighting. A line with an FM click, a jump of most of a cycle between two samples, takes the color of the lines above it, because the click rings in the chroma band as a dash of saturated color.

Color switches on only when a field has enough locked hsyncs and a strong enough burst, and stays on until they fall further. Otherwise the P4 draws black and white rather than colored noise.

Fields to frames

Each field carries 240 picture lines, every other row of a 720 by 480 frame. The luma of each line is a weighted sum of seven samples, wider on lines that start half a sample late, and one pixel is about one sample.

The P4 sends a full frame for every field. Each missing row takes the median of three values for its luma: the row above, the row below, and the same row from the previous field. Its chroma comes from the row below. Where the picture is still, the median usually picks the previous field's row and keeps full detail. Where it moves, it falls back to a neighboring line.

A bright bar moving across a picture with some fine shading. Each missing row takes the median of the row above, the row below and the same row of the previous field. Turn off the previous field to see what averaging only the neighboring rows costs.

Rows of the previous field and the finished strips of the frame move between memory and the PSRAM frame by DMA, so the cores spend their time on the pixels. Each row is also pulled toward the same row two fields back, by less as the difference grows and not at all past a threshold that follows the measured noise, so noise settles without smearing motion.

The hardware JPEG encoder turns each frame into an MJPEG image, lowering its quality when a noisy frame would not fit, and the P4 sends it over high-speed USB as a standard webcam, with a serial console on the same cable.

What runs where

A sample passes through hardware first, then two CPU tasks on different cores, then hardware again. The demodulator and sync detector get a core to themselves, because at 13.33 million samples a second a 360 MHz core has 27 clock cycles for each one. The numbers follow one sample's path, and the lettered box is a control loop that runs beside it.

Hardware, no CPU

  • 1PARLIO RXCaptures the C5's 14 lanes on its clock.
  • 2BitScramblerLooks up each sample's phase and magnitude code, four cycles a sample.
  • 3GDMA into the ring24 nodes of 4032 bytes in internal RAM, about 3.6 ms of samples.
  • 6GDMA to PSRAMCopies finished rows into the frame, and brings back rows of the previous field.
  • 8JPEG engineEncodes each frame.
  • 10USB controllerHigh-speed device, video and serial console.

Core 1

  • 4decode task, priority 10Reads the ring, takes differences, repairs wraps, slices sync, runs the line clock, tracks levels, and mixes chroma for each line. Queues up to 40 lines for drawing.
  • 7video task, priority 4Hands each finished field to the JPEG engine. It runs below the demodulator.

Core 0

  • 5draw task, priority 7Filters luma, finishes the color, reduces noise, deinterlaces, and starts the copies to PSRAM.
  • Acontrol task, priority 6, every 50 msSteers the C5's gain, measures the offset and rebuilds the phase table, which takes a couple of milliseconds.
  • main task, priority 6The console and the bridge that flashes the C5.
  • UI task, priority 4The OLED and the buttons.

Either core, not pinned

  • 9uvc task and usb task, priority 5Feed frames to the USB video class and run the USB stack.
  • C5 relay task, priority 5Reads the C5's serial output: replies to commands, its log, and the flashing bridge's traffic.
  • stall watchdogRestarts the P4 if the console, USB or video tasks stop responding.
The tasks and hardware in the P4's firmware, grouped by where they run. The interrupt for the PARLIO receiver is on core 0 too. Priorities are FreeRTOS priorities, and a higher number preempts a lower one on the same core.

The control loop is the one place the two sides touch. The control task reads statistics that the decode task collects from the second byte of each sample, sends a new gain to the C5, and builds a new table. The decode task is the one that loads it, stopping the receive for about 17 lines in the last picture lines of a field.

A line crosses from core 1 to core 0 through a queue of 40 lines. The ring of demodulated samples holds about 38 lines, so a line that waits longer would be drawn from samples already overwritten, and the draw task drops it.

The same job, step by step

Both receivers turn the radio's I and Q into video, but they spend their resources differently. The C5 has fast peripherals and no CPU time to spare, so it uses small tables and stays analog. The P4 has CPU time and memory, so it uses finer numbers and decodes the video itself.

Step ESP32-C5 ESP32-P4
Samples taken 4 bits each of I and Q in one byte, 40 million a second 7 bits each of I and Q from the C5's bus, 13.33 million a second
Phase BitScrambler lookup of the byte, 32 steps around the circle BitScrambler lookup in two steps, through 32 log-spaced magnitude codes per lane, 256 steps around the circle
Frequency BitScrambler table of previous and current phase, 625 kHz a step CPU subtracts the previous phase with vector code, 52 kHz a step
Wrapped noise Table sends large steps to blanking Wrapped samples are moved to the end of the range they passed
Gain control Reads 256 samples every 0.2 ms, forces a vendor gain entry in fine, baseband and RF steps, and adds range lanes at maximum gain Reads magnitude codes and signs every 50 ms, and moves one gain index, about 1 dB a step, to hold an RMS of 40 to 72
Weak signals Coarse grid, angles jump near the center Fine grid near the center, a gain loop and a table recentered on the measured offset
Sync and levels Passed along inside the analog video Found by a sync slicer and a line clock, with levels averaged from sync tips and back porches
Color Passed along as the subcarrier Decoded against a 704-entry reference oscillator, with a comb filter
CPU None: peripherals and DMA do all of it Core 1 demodulates and finds sync, core 0 draws
Output 6-bit resistor ladder, analog video MJPEG frames over USB, as a webcam