My first attempt at building a phasing direct conversion receiver.
To make the experience fit your profile, pick a username and tell us what interests you.
We found and based on your interests.
Having got to the point where I can listen to the output of the receiver in real-time, I decided to spend some time trying to improve the quality of the received audio. Here's a recording of the result: receiver-test3.mp3
This is noticeably better than the output I shared previously. I think most of the improvement comes from using a better antenna, though this is still just a random wire. I also added about 6 dB more gain (to the ADC buffer stage) in order to use more of the available ADC resolution. (I might be using something like 7 bits now rather than 6.) It's also possible that I did a better job of setting the various DC offset adjustments of the receiver than I did before.
However, there's still room for improvement. In particular, there's some background hiss and a distinctive periodic noise pattern, both of which can be heard in the quieter section of the recording above. I'm not currently using a de-emphasis filter, which I assume would help with the hiss. Beyond that, I don't yet know which aspects of the receiver I need to improve to further improve the audio quality. Maybe someone reading this will recognise the periodic noise as being indicative of a particular problem, and point me in the right direction!
When I initially tested the receiver I had to first capture a chunk of IQ samples, then later perform demodulation as a post-processing step. This was fine for an initial test, but doing this doesn't feel much like using a "radio", and more importantly it makes it awkward to experiment with the system.
What I needed was a real-time implementation of demodulation. I considered implementing something from "scratch", but in the end chose to use GNU Radio.
The main challenge in getting this working was figuring out how to get data from the receiver into GNU Radio. The solution I've settled on for now is to run a small auxiliary program that consumes data from the receiver (which looks like a serial device on the PC) and forwards it to GNU Radio over ZeroMQ.
The program itself is conceptually simple; it's just a endless loop that reads a bunch of IQ samples from stdin (which I redirect to the serial device presented by the receiver), converts each sample from a pair of bytes to a complex number, then hands the complex numbers off to ZeroMQ. Here's a sketch of this in Python:
import zmq import numpy as np ctx = zmq.Context() socket = ctx.socket(zmq.PUB) # local IP on which we're running socket.bind('tcp://192.168.1.208:1234') BUFSIZE = 1024 * 8 buf = np.zeros(BUFSIZE, dtype=np.complex64) while True: for n in range(BUFSIZE): i, q = sys.stdin.buffer.read(2) buf[n] = (2*(i/255.)-1) + (2*(q/255.)-1)*1j socket.send(buf)
While this illustrates the basic idea, things are a little more complicated in practice. In particular, the receiver doesn't actually produce a simple stream of alternating I/Q bytes as the code above assumes. Instead, I'm doing some crude framing in order to distinguish I/Q sample pairs from one another. As a result, the program also implements a small state machine, and it's that which extracts raw I/Q bytes from the serial stream.
That detail aside, with this program in place, data can be brought into GNU Radio by adding a ZMQ SUB Source block to the flow graph, and configuring it with the IP address to which ZeroMQ is bound. (It might be advantageous for this to be 127.0.0.1 if the program and GNU Radio are on the same host.)
Below is a screenshot of the complete flow graph I created for receiving broadcast FM using this approach. It streams data from the receiver, performs demodulation, then sends the result to an audio device for playback.
The signal processing here is equivalent to the Python code I used previously, but with this I can now listen to the output of the receiver in real-time.
I've realised that I didn't implement the decimation step correctly when processing the output of my first receiver test.
In particular, I didn't low-pass filter the demodulated signal prior to downsampling, meaning that other signals present in the broadcast FM baseband signal (e.g. the stereo audio subcarrier) were aliased in to the mono audio channel I extracted.
I've fixed that, and reprocessed the IQ capture used in my previous post. The resulting audio is noticeably improved.
Here's the updated code:
import sys import numpy as np from scipy.signal import firwin from scipy.io import wavfile def pk(arr): return np.max(np.abs(arr)) def norm(chan): chan0 = chan - np.mean(chan) out = chan0 / pk(chan0) return out def demod(xt): return np.diff(np.unwrap(np.angle(xt))) def main(infile, outfile): channel = np.load(infile) sample_rate = 250e3 M = 6 # decimate by this iq = norm(channel[0]) + 1j*norm(channel[1]) x = demod(iq) # low pass filter taps = firwin(numtaps=101, cutoff=15e3, fs=sample_rate) x = np.convolve(x, taps, 'valid') x = x[::M] # decimate x /= pk(x) # normalize x *= 32767 x = x.astype(np.int16) wavfile.write(outfile, int(sample_rate / M), x) if __name__ == '__main__': if len(sys.argv) < 3: print(f'usage: {sys.argv[0]} ') else: main(sys.argv[1], sys.argv[2])
As an aside, I note that this fix doesn't help with the two additional captures from which I'm yet to extract recognisable audio.
I've now built most of the building blocks of my receiver design. The only thing I don't have is the anti-aliasing filters, and I guess I can live without those for now. So I decided to assemble the entire receiver for an end to end test.
Here's what it looks like:
I configured the LO for 101.1 MHz to tune the receiver to Classic FM, since I know its signal is strong here. I set the IF amplifiers to their max gain since I know the overall system only just has enough gain, even for strong signals.
Each IF amplifier has a DC offset adjustment which needs to be manually tuned. I'm currently doing this by looking at the output of the IF stage on the scope. I initially had trouble here, because I didn't have enough adjustment to trim out the DC offset I saw in practice. I based the initial adjustment range on the DC offset I saw when testing the mixer standalone, but in practice the offset is much larger. My current guess is that energy is radiating directly from the LO to the antenna, causing a lot more self mixing than I saw when testing without an antenna. I temporarily worked around this by reducing the value of the resistors between the pots and the op-amp inputs, which increases the range of available adjustment.
With the DC offset adjusted as best I could, I captured three short chunks of IQ data from the ADC. This is just a case of piping data from a serial device to a file on my laptop. I then wrote a little bit of Python code to demodulate the captured IQ data and extract the mono audio channel.
Here's the result: receiver-test.mp3
Clearly the quality is terrible, but at least it does something. In truth, I only managed to get recognisable audio out of one of the captures I made, and I don't yet know why.
Now that I have something working I can think about how to improve it. I've not decided how to go about that, but some initial ideas are:
For posterity, here's the gist of the Python code I mentioned above:
import sys import numpy as np from scipy.io import wavfile def pk(arr): return np.max(np.abs(arr)) def norm(chan): chan0 = chan - np.mean(chan) out = chan0 / pk(chan0) return out def demod(xt): return np.diff(np.unwrap(np.angle(xt))) def main(infile, outfile): channel = np.load(infile) sample_rate = 250e3 D = 6 # decimate by this iq = norm(channel[0]) + 1j*norm(channel[1]) x = demod(iq) x = x[::D] # decimate x /= pk(x) # normalize x *= 32767 x = x.astype(np.int16) wavfile.write(outfile, int(sample_rate / D), x) if __name__ == '__main__': if len(sys.argv) < 3: print(f'usage: {sys.argv[0]} <infile> <outfile>') else: main(sys.argv[1], sys.argv[2])
My receiver design follows the well-trod path of sampling I and Q signals in order to perform demodulation in software. Since (a) my initial goal is to receive broadcast FM (which has a bandwidth of around 250 kHz), and (b) I'm using the zero-IF approach, then I'll need to run each ADC at 250 ksps or greater. I'd like to do the DSP on a PC to start with, so I'll need to transfer those samples over e.g. USB. If the sample resolution is 8 bits then the required data transfer rate is 500 kB/s or greater.
The quickest way I could think of to meet these requirements was to build something based around the built-in ADC on a RP2040 microcontroller, since I already own a Raspberry Pi Pico or two. Here's a sketch of what I'm came up with:
I'm using a FT232H (on an Adafruit breakout board) to interface to USB because I couldn't immediately figure out how to achieve the required transfer rate using the microcontroller's built-in USB.
The FT232H is configured to operate in asynchronous FIFO mode. In this mode, a byte can be transferred over USB simply by asserting data on to the 8 data pins and toggling the write pin.
On the RP2040 the ADC is running in round-robin mode, producing interleaved samples from two inputs, each at 250 ksps. DMA is used to transfer 8 bit samples to a PIO block, which waggles the pins on the FT232H as required. The upshot is that the CPU isn't involved in running the ADC or transferring the data, beyond performing the initial hardware set-up.
On the front-end I have a pair of op-amps (MC3317N) to shift the inputs into the 0 to 3.3V input range of the ADC.
I built this design on a solderless breadboard:
Here's a plot of data captured while driving one of the inputs with my homebrew signal generator:
I suspect there are a few potential problems with this approach:
Nevertheless, my hope is that this set-up will be good enough for an initial test of the receiver.
From the work I've described in previous logs, I now have a sense of what I'll need from the IF amplifier stage of my receiver, so I set out to design something around the following specs:
Here's my first pass at such a design:
The overall design is split across two separate op-amp gain stages.
The first stage is a non-inverting amplifier, with a fixed gain of around 25 dB.
The second stage is an inverting amplifier, with gain which can be varied between around 6 and 30 dB. The adjustment is made manually using a variable resistor. The summing configuration is used to introduce a small, manually adjusted, DC offset to compensate for any DC offset generated by the mixer. Any such offset will have been amplified by the first stage, meaning I might see an offset of about 70 mV here (assuming a 4 mV offset out of the mixer), which seems tolerable. With this arrangement the DC offset and gain adjustments operate independently.
I have a 50 ohm resistor on the input to give a broadband match. The high input impedance of the first stage means the overall input impedance is set by this resistor.
I also use a resistor to set the output impedance to 50 ohms. The idea here is that this will provide termination when driving e.g. a high impedance load over a length of coax. The op-amp probably can't supply enough current to drive a 50 ohm load at anything other than low amplitudes, so such a coax won't typically be terminated by the load.
For the op-amps I elected to use NE5532s since they are (a) readily available in small quantities, (b) inexpensive, (c) available in an easy to work with DIP package, and (d) have just about enough bandwidth.
A built a prototype of this circuit, which looks like this:
I wanted to measure the gain and bandwidth of the circuit to check it meets the spec. I attempted this on my LiteVNA, but the results I got at these low frequencies didn't look great, and besides, it only goes down to 50 kHz. So instead, I made the measurements manually by driving the amplifier with a home brew signal generator and measuring the output on my scope.
This plot shows gain vs frequency at the circuit's minimum and maximum gain settings:
This looks pretty good to me. The gain is rolling off faster than predicted by the simulations I did in LTSpice, which I don't understand. (Inaccuracy in the op-amp model I used perhaps?) However, the 3dB bandwidth at maximum gain is roughly 125 kHz, which I'll call good enough.
I also measured S11 to check that the input impedance will provide a good 50 ohm match for the mixer output. The unused mixer product at twice the local oscillator frequency will be at 216 MHz at the upper end of the broadcast FM band, so I made the measurement up to 300 MHz. I had a 20 dB attenuator between the VNA and the amplifier, which I calibrated out, and I set the VNA to its minimum output power. (Though I'm not sure either of these steps were necessary since the amplifier doesn't have any gain at the swept frequencies.) Here's the result:
Again, this looks good.
Finally, I note that I've also noodled with the DC offset adjustment which seems to work as expected.
Having convinced myself that the Si5351 can directly drive a pair of ADE-1 mixers, I decided to prototype the mixer stage of the receiver. The main goal was to confirm that the mixing action is working with this set-up, and to make some measurements of the outputs. Here's the circuit diagram:
And here's what the build looks like:
In order to make some initial measurements I drove the RF input with an LC oscillator I happen to have built previously. This generates a ~120 MHz tone with an amplitude of 400 mV peak-to-peak into 50 ohms. The LO inputs were driven directly by the Si5351 set to generate quadrature outputs at ~110 MHz.
With this set-up the main mixing products are at ~10 MHz and ~230 MHz. In the final receiver I'll only be interested in the first of these, so I decided to take a look at just that component of the IF outputs by enabling the scope's 20 MHz bandwidth limit. Here's what that looks like with the inputs AC coupled:
This seems to be working pretty well! I expect a 6 dB loss from the splitter and a further 5 dB loss from the mixer, so for a 400 mV p-p input I'd expect 113 mV p-p outputs, which is very close to what I'm getting. Further, the scope is measuring a phase difference of close to 90 degrees between the outputs as desired.
One wrinkle is the small difference in amplitude between the outputs of around 3 mV. I expect asymmetry here to eventually show up as degraded receiver performance. I don't know what form that will take or how significant it will be, but for now I'll assume it won't prevent me getting something out of an initial build.
Finally, I note that I also measured the DC offset of each output to be around -4 mV. Such offsets present a challenge for zero-IF designs since they are within the band of interest of the IF signals. Knowing just their approximate magnitude is hopefully a first step towards me understanding how I might handle this.
In my previous log I shared the code I used to configure the Si5351 to generate two 100 MHz clock signals that were 90 degrees out of phase. However, looking at the outputs on the scope more closely, I now see that they were actually more like 70 degrees out of phase. You can see this in the capture I shared previously.
After a bit of trial and error, I've discovered that I can fix this by setting the multi-synth stages to fractional (rather than integer) division mode. i.e. Set bit 6 of registers 16 and 18 to zero. (I'm using the clk0 and clk2 outputs.) With the Si5351 library I'm using, this can most easily be done by setting up the dividers using configure_fractional
rather than configure_integer
.
Here's the updated code:
import board import busio import adafruit_si5351 # Set the following environment variable when using the Pico: # export BLINKA_U2IF=1 i2c = busio.I2C(board.SCL, board.SDA) si5351 = adafruit_si5351.SI5351(i2c) # 25 MHz (crystal) * 24 = 600 MHz si5351.pll_a.configure_integer(24) # 600 MHz / 6 = 100 MHz si5351.clock_0.configure_fractional(si5351.pll_a, 6, 0, 1) si5351.clock_2.configure_fractional(si5351.pll_a, 6, 0, 1) # 90 degree phase offset for output 0 si5351._write_u8(165, 6) si5351.reset_plls() si5351.outputs_enabled = True
And here's what the outputs now look like on the scope (AC coupled as before):
The phase shift still isn't exactly 90 degrees as measured by the scope, but this is visibly better than what I had before. I'll call this is good enough for now...
One final thought... The fact that the divider needs to be set to an integer value in order to obtain a shift of exactly 90 degrees is well documented on the web. However, I've not seen anyone mention that fractional division mode must also be used. This is curious, since using integer division mode is said to reduce jitter, so it's likely plenty of people have tried to generate a 90 degree phase shift while using it. Given this, I'm surprised I've not seen this documented, which makes me wonder whether I'm missing something?
I'm considering using the Si5351 as the quadrature oscillator in my receiver. I've read a lot about this part, but I'm still unsure how it will perform in practice, so I bought a breakout board to experiment with.
In order to make experimentation easy, I'm using a set-up that allows me to configure the part using Python code running on my laptop. In particular, I'm running Adafruit's Blinka and their Si5351 library on my laptop, and using a Raspberry Pi Pico running the u2if firmware to interface between the laptop and the Si5351.
Here's some code I ran as an initial test. It sets up two 100 MHz outputs that are 90 degrees out of phase:
import board import busio import adafruit_si5351 # Set the following environment variable when using the Pico: # export BLINKA_U2IF=1 i2c = busio.I2C(board.SCL, board.SDA) si5351 = adafruit_si5351.SI5351(i2c) # 25 MHz (crystal) * 24 = 600 MHz si5351.pll_a.configure_integer(24) # 600 MHz / 6 = 100 MHz si5351.clock_0.configure_integer(si5351.pll_a, 6) si5351.clock_2.configure_integer(si5351.pll_a, 6) # 90 degree phase offset for output 0 si5351._write_u8(165, 6) si5351.reset_plls() si5351.outputs_enabled = True
And here's what that looks like on the scope:
One key thing I'd like to understand is whether this part can provide the 7 dBm of power required to drive the ADE-1 mixer I plan to use. My understanding is that this figure indicates the power that the LO ought to deliver to a 50 ohm load. To measure this, I connected the breakout board to the 50 ohm input of my scope (AC coupled), and used the FFT to determine the amplitude of the oscillator's fundamental frequency, from which I calculated the power. The output drive strength of the part can be adjusted between 4 levels (called 2, 4, 6, and 8 mA) so I repeated the measurement for each level. Here are the results:
Drive Strength (mA) | Amplitude (dBV RMS) | Power (dBm) |
2 | -10.4 | 2.6 |
4 | -6.0 | 7.0 |
6 | -3.4 | 9.6 |
8 | -1.4 | 11.6 |
If this is correct then it seems the part can at least deliver sufficient power. In truth, I'm not at all confident I'm doing the right thing here, but this is sufficiently promising that I'm happy to proceed.
However, one thing I know I don't understand is the choice of names given to the different drive strength levels, since I can't see how these currents correspond to e.g. the amplitude of the outputs as measured on the scope. Please leave a comment if you can help me with this!
I think I've figured out what caused the step in S21 that I mentioned in my previous log.
The LiteVNA uses two different oscillators to drive the DUT, called LO and HI, with LO being used below 100 MHz and HI above. It turns out the drive strengths of these two oscillators is slightly different. This alone isn't an issue, but it also turns out that the default drive strength is such that the LNA output is driven pretty hard in my set-up. I suspect this puts the LNA in a (non-linear) region where gain depends on drive strength, hence the step in S21.
Happily the drive strength can be adjusted, and if I reduce the drive for both LO and HI (to minimum) then the step in S21 disappears:
Create an account to leave a comment. Already have an account? Log In.
Become a member to follow this project and never miss any updates