Lab 3.3 — Digital Mixing and Frequency Shift¶
Goal¶
Shift a complex IQ signal in frequency using a numerically controlled oscillator (NCO) and complex multiplication.
This lab is the direct bridge between SDR software processing and FPGA blocks such as DDS/NCO, complex multiplier, frequency translator and digital downconverter.
Theory¶
Digital mixing multiplies the input complex signal by a complex exponential:
y[n] = x[n] * exp(j * 2*pi*f_shift*n/Fs)
For a complex baseband signal, this shifts the spectrum by f_shift.
Important concepts:
- complex sinusoid;
- NCO / DDS;
- phase accumulator;
- frequency resolution;
- complex multiplication;
- positive and negative frequency shifts;
- spectral images and wraparound;
- fixed-point phase and sine/cosine representation.
Experiment¶
Generate or load an IQ signal with a tone or modulated waveform located away from DC.
Then:
- estimate or define the input frequency offset;
- generate an NCO;
- multiply the signal by the NCO;
- compare spectra before and after frequency shift;
- verify that the target signal moves to the expected frequency;
- discuss NCO frequency resolution and fixed-point phase accumulator width.
Run the reference script¶
python blocks/block_03_dsp_basics/python/lab_3_3_digital_mixing.py
The script is deterministic and writes:
docs/assets/lab33_digital_mixing.png
docs/assets/lab33_digital_mixing_metrics.json
Run it first and read its numbers against the section What to expect below. Then write your own version from the minimum structure that follows; the reference script is your answer key.
Python implementation¶
Minimum structure for your own implementation:
import numpy as np
import matplotlib.pyplot as plt
fs = 2.4e6
n = 32768
t = np.arange(n) / fs
f0 = 420e3
x = np.exp(1j * 2*np.pi*f0*t)
f_shift = -420e3
nco = np.exp(1j * 2*np.pi*f_shift*t)
y = x * nco
freq = np.fft.fftshift(np.fft.fftfreq(n, d=1/fs))
X = np.fft.fftshift(np.fft.fft(x * np.hanning(n)))
Y = np.fft.fftshift(np.fft.fft(y * np.hanning(n)))
plt.figure()
plt.plot(freq/1e3, 20*np.log10(np.maximum(np.abs(X), 1e-12)), label="before")
plt.plot(freq/1e3, 20*np.log10(np.maximum(np.abs(Y), 1e-12)), label="after")
plt.grid(True)
plt.xlabel("Frequency, kHz")
plt.ylabel("Magnitude, dB")
plt.legend()
plt.show()
MATLAB implementation¶
Minimum structure for your own implementation:
fs = 2.4e6;
N = 32768;
t = (0:N-1).' / fs;
f0 = 420e3;
x = exp(1j*2*pi*f0*t);
fShift = -420e3;
nco = exp(1j*2*pi*fShift*t);
y = x .* nco;
freq = fftshift((-floor(N/2):ceil(N/2)-1).' * fs / N);
X = fftshift(fft(x .* hann(N)));
Y = fftshift(fft(y .* hann(N)));
figure; hold on;
plot(freq/1e3, 20*log10(max(abs(X), 1e-12)), 'DisplayName', 'before');
plot(freq/1e3, 20*log10(max(abs(Y), 1e-12)), 'DisplayName', 'after');
grid on;
xlabel('Frequency, kHz');
ylabel('Magnitude, dB');
legend('Location', 'best');
C++ bridge¶
A future C++ primitive should separate NCO generation from complex multiplication:
struct NcoState {
uint64_t phase;
uint64_t phase_step;
};
std::complex<float> next_nco_sample(NcoState& nco);
std::complex<float> mix_sample(std::complex<float> x, std::complex<float> osc);
Validation tests:
- generated frequency matches target shift;
- phase continuity is preserved;
- positive and negative shifts are correct;
- mixed tone lands at expected FFT bin;
- fixed-point NCO error is bounded.
FPGA / Verilog bridge¶
Hardware mapping:
| Function | RTL block |
|---|---|
| phase accumulation | NCO phase accumulator |
| sine/cosine generation | LUT, CORDIC or vendor DDS |
| complex multiply | four multipliers or optimized three-multiplier form |
| output scaling | fixed-point rounding and saturation |
Streaming interface target:
clk, rst
in_valid, in_i, in_q
out_valid, out_i, out_q
NCO frequency resolution¶
For an accumulator of width A bits:
df = Fs / 2^A
| Accumulator width | Frequency resolution |
|---|---|
| 24 bit | Fs / 16,777,216 |
| 32 bit | Fs / 4,294,967,296 |
| 48 bit | Fs / 281,474,976,710,656 |
Expected plots¶
Produce at least:
- spectrum before mixing;
- spectrum after mixing;
- zoomed view around target frequency;
- optional phase accumulator trace;
- optional NCO quantization error plot.
What to expect¶
Default run (Fs = 2.4 MS/s, N = 32768, tone at +420 kHz, shift -420 kHz; the hardware-style NCO
uses a 32-bit phase accumulator addressing a 1024-entry, 16-bit sine/cosine table):
Input peak: 419970.7 Hz
After ideal NCO (-420 kHz): 0.0 Hz
After wrong-sign NCO (+420 kHz): 840014.6 Hz
32-bit phase increment: 3543348019 -> realized shift -420000.000112 Hz (error -0.000112 Hz, resolution 0.000559 Hz)
After hardware NCO: 0.0 Hz, worst NCO spur -58.9 dBc
- Peak readings are bin-quantized. The input reads 419 970.7 Hz, not 420 000, because the bin spacing is 73.2 Hz; do not mistake this for a frequency error.
- A sign mistake does not fail loudly. With the opposite sign the tone moves away to 840 kHz and the output still looks like a clean tone. Always check where the peak lands.
- A negative shift is a large unsigned increment.
-420 kHzbecomes increment 3 543 348 019 (that is 2^32 - 751 619 277); the accumulator wraps and the shift is realized to 0.1 mHz, well inside the 0.56 mHz resolution of a 32-bit accumulator. - The table size, not the accumulator, limits purity. Truncating the phase to 10 address bits gives a worst spur of -58.9 dBc. Each extra address bit buys about 6 dB (8 bits: -46.8 dBc, 12 bits: -70.8 dBc); the 16-bit amplitude only starts to matter below about 8 bits.

Exercises¶
- Call
hardware_nco(SHIFT_HZ, lut_addr_bits=a)fora= 6, 8, 10, 12 and plot the worst spur againsta. Fit a line. What table size do you need for a 90 dBc spur-free NCO? - Repeat with
amp_bits= 8, 12, 16 at 10 address bits. Why does amplitude resolution hardly matter here? - Choose a shift that is an exact binary fraction of
Fs(for exampleFs / 16). What happens to the spurs, and why? - Replace the complex multiply with a real one (
np.real(x) * np.cos(...)). Where does the extra spectral line appear, and why does a real mixer need an image filter?
Answers (checked by running the lab script)
- 6, 8, 10 and 12 address bits give −36.1, −48.2, −60.3 and −72.2 dBc: about 6 dB per bit,
≈ −6·a dBc. A 90 dBc spur-free NCO needs a 15-bit address, a 32 768-entry table, which is why real NCOs use phase dithering or CORDIC instead. - At 10 address bits: 8, 12 and 16 amplitude bits give −55.3, −60.2 and −60.3 dBc. From 12 bits on, the phase truncation (−60 dBc) dominates and more amplitude bits buy nothing; only 8 bits becomes the limit.
- With a shift of
Fs/16the phase increment is an exact multiple of the table step, so no phase is ever truncated: the worst spur drops to −100.6 dBc, set only by the amplitude quantization. Truncation spurs exist only when the phase sequence does not land on table entries. - A real mixer multiplies by
cos, which contains both+fand−f: besides the wanted line at 0 Hz you get a second one at 840 kHz (420 + 420 kHz). A real mixer produces sum and difference, so it needs a filter to remove the unwanted one.
Report checklist¶
- State
Fs, input tone frequency and target shift. - Show the complex NCO equation.
- Plot spectrum before and after mixing.
- Verify the final frequency location.
- Explain positive vs negative frequency shifts.
- Calculate NCO resolution for at least 32-bit phase accumulator.
- Explain how the mixer maps to FPGA.
- Discuss fixed-point scaling and saturation.
Engineering conclusion template¶
Digital mixing shifts the signal from ____ kHz to ____ kHz. In FPGA, the same
operation becomes an NCO plus complex multiplier. The main hardware trade-offs
are phase accumulator width, sine/cosine generation method, multiplier count and
output scaling.