GNURadio PSK31 Decoder, Part 1

I first heard about the GNURadio project when it was mentioned in a Slashdot post earlier this year. Around that time, I was studying to get my amateur radio license (after some years of encouragement from VE7TIL) but I was not sure where to go with it. Being somewhat familiar with Python, it seemed like an interesting avenue to explore. I had heard about digital modes such as PSK31 in my amateur radio studies, and a PSK31 decoder seemed like a reasonable first project using GNURadio.

There is plenty of background information about PSK31 on the web, so let’s just jump right in. PSK31 uses a variable-length encoding (Varicode) to represent ASCII characters 0-127. This is easily represented in Python using a dictionary. We’ll need this in order to turn the stream of bits into something useful.

decode = {
    '1010101011' : '\x00',    '1011011011' : '\x01',
    '1011101101' : '\x02',    '1101110111' : '\x03',
    '1011101011' : '\x04',    '1101011111' : '\x05',
    '1011101111' : '\x06',    '1011111101' : '\x07',
    '1011111111' : '\x08',    '11101111'   : '\x09',
    '11101'      : '\x0A',    '1101101111' : '\x0B',
    '1011011101' : '\x0C',    '11111'      : '\x0D',
    '1101110101' : '\x0E',    '1110101011' : '\x0F',
    '1011110111' : '\x10',    '1011110101' : '\x11',
    '1110101101' : '\x12',    '1110101111' : '\x13',
    '1101011011' : '\x14',    '1101101011' : '\x15',
    '1101101101' : '\x16',    '1101010111' : '\x17',
    '1101111011' : '\x18',    '1101111101' : '\x19',
    '1110110111' : '\x1A',    '1101010101' : '\x1B',
    '1101011101' : '\x1C',    '1110111011' : '\x1D',
    '1011111011' : '\x1E',    '1101111111' : '\x1F',
    '1'          : ' ',       '111111111'  : '!',
    '101011111'  : '"',       '111110101'  : '#',
    '111011011'  : '$',       '1011010101' : '%',
    '1010111011' : '&',       '101111111'  : '\'',
    '11111011'   : '(',       '11110111'   : ')',
    '101101111'  : '*',       '111011111'  : '+',
    '1110101'    : ',',       '110101'     : '-',
    '1010111'    : '.',       '110101111'  : '/',
    '10110111'   : '0',       '10111101'   : '1',
    '11101101'   : '2',       '11111111'   : '3',
    '101110111'  : '4',       '101011011'  : '5',
    '101101011'  : '6',       '110101101'  : '7',
    '110101011'  : '8',       '110110111'  : '9',
    '11110101'   : ':',       '110111101'  : ';',
    '111101101'  : '<',       '1010101'    : '=',
    '111010111'  : '>',       '1010101111' : '?',
    '1010111101' : '@',       '1111101'    : 'A',
    '11101011'   : 'B',       '10101101'   : 'C',
    '10110101'   : 'D',       '1110111'    : 'E',
    '11011011'   : 'F',       '11111101'   : 'G',
    '101010101'  : 'H',       '1111111'    : 'I',
    '111111101'  : 'J',       '101111101'  : 'K',
    '11010111'   : 'L',       '10111011'   : 'M',
    '11011101'   : 'N',       '10101011'   : 'O',
    '11010101'   : 'P',       '111011101'  : 'Q',
    '10101111'   : 'R',       '1101111'    : 'S',
    '1101101'    : 'T',       '101010111'  : 'U',
    '110110101'  : 'V',       '101011101'  : 'W',
    '101110101'  : 'X',       '101111011'  : 'Y',
    '1010101101' : 'Z',       '111110111'  : '[',
    '111101111'  : '\\',      '111111011'  : ']',
    '1010111111' : '^',       '101101101'  : '_',
    '1011011111' : '`',       '1011'       : 'a',
    '1011111'    : 'b',       '101111'     : 'c',
    '101101'     : 'd',       '11'         : 'e',
    '111101'     : 'f',       '1011011'    : 'g',
    '101011'     : 'h',       '1101'       : 'i',
    '111101011'  : 'j',       '10111111'   : 'k',
    '11011'      : 'l',       '111011'     : 'm',
    '1111'       : 'n',       '111'        : 'o',
    '111111'     : 'p',       '110111111'  : 'q',
    '10101'      : 'r',       '10111'      : 's',
    '101'        : 't',       '110111'     : 'u',
    '1111011'    : 'v',       '1101011'    : 'w',
    '11011111'   : 'x',       '1011101'    : 'y',
    '111010101'  : 'z',       '1010110111' : '{',
    '110111011'  : '|',       '1010110101' : '}',
    '1011010111' : '~',       '1110110101' : '\x7F' }

This article discusses how to create PSK31 demodulator using a bandpass filter, a delay line, a balanced modulator, an AM detector and a PLL. I grabbed a PSK31 sound sample off the web, in which a quick brown fox jumps over a lazy dog. This particular fox jumps over the lazy dog at a sample rate of 11.025kHz using a 1kHz carrier with no other noise or signals to worry about. In the interest of cobbling something together quickly, we can drop the bandpass filter for now and see what we can get out of a multiplier, a delay line and a lowpass filter. The code below reads in the wave file and outputs a file of raw floating-point values. This can easily be read into Audacity using File -> Import -> Raw Data with the appropriate settings.

from gnuradio import gr
from gnuradio import audio

class psk31_demod(gr.top_block):

    def __init__(self):
        gr.top_block.__init__(self)

        sample_rate = 11025

        # Audio source (.wav file)
        src = gr.wavfile_source("./bpsk31.wav", False)

        # Raw float data output file.
        dst = gr.file_sink(4, "./bpsk31_out.raw")

        # Delay line. This delays the signal by 32ms
        dl = gr.delay(gr.sizeof_float, int(round(sample_rate/31.25)))

        # Multiplier
        # Multiplying the source and the delayed version will give us
        # a negative output if there was a phase reversal and a positive output
        # if there was no phase reversal
        mul = gr.multiply_ff(1)

        # Low Pass Filter. This leaves us with the envelope of the signal
        lpf_taps = gr.firdes.low_pass(
            5.0,
            sample_rate,
            15,
            600,
            gr.firdes.WIN_HAMMING)
        lpf = gr.fir_filter_fff(1, lpf_taps)

        # Connect the blocks.
        self.connect(src, dl)
        self.connect(src, (mul, 0))
        self.connect(dl,  (mul, 1))
        self.connect(mul, lpf)
        self.connect(lpf, dst)

# Instantiate the demodulator
if __name__ == '__main__':
    try:
        psk31_demod().run()
    except KeyboardInterrupt:
        pass

It is possible to see the message in the screen shot of the raw data which was loaded into Audacity. I annotated this image to make the message more clear. Remember that in PSK31, two or more consecutive 0s indicates a break between characters.

Annotated screen shot showing the PSK31 data.So far we’ve recovered a line feed and a definite article from a strange looking wave. We should be able to get this looking a little more digital using GNURadio modules. According to the GNURadio API Reference, there is a module called gr.binary_slicer_fb which will do this for us. The “_fb” part indicates that the input is float while the output is binary. In reality it outputs a byte which is either 0x00 or 0x01.

from gnuradio import gr
from gnuradio import audio

class psk31_demod(gr.top_block):

    def __init__(self):
        gr.top_block.__init__(self)

        sample_rate = 11025

        # Audio source (.wav file)
        src = gr.wavfile_source("./bpsk31.wav", False)

        # Raw float data output file.
        dst = gr.file_sink(1, "./bpsk31_dig_out.raw")

        # Delay line. This delays the signal by 32ms
        dl = gr.delay(gr.sizeof_float, int(round(sample_rate/31.25)))

        # Multiplier
        # Multiplying the source and the delayed version will give us
        # a negative output if there was a phase reversal and a positive output
        # if there was no phase reversal
        mul = gr.multiply_ff(1)

        # Low Pass Filter. This leaves us with the envelope of the signal
        lpf_taps = gr.firdes.low_pass(
            5.0,
            sample_rate,
            15,
            600,
            gr.firdes.WIN_HAMMING)
        lpf = gr.fir_filter_fff(1, lpf_taps)

        # Binary Slicer (comparator)
        slc = gr.binary_slicer_fb()

        # Connect the blocks.
        self.connect(src, dl)
        self.connect(src, (mul, 0))
        self.connect(dl,  (mul, 1))
        self.connect(mul, lpf)
        self.connect(lpf, slc)
        self.connect(slc, dst)

# Instantiate the demodulator
if __name__ == '__main__':
    try:
        psk31_demod().run()
    except KeyboardInterrupt:
        pass

In this case the raw data was read in to Audacity as 8-bit signed PCM and then amplified. This screenshot shows the same data as before.

Screenshot showing PSK31 data after passing through binary slicer.

The next problem is when to sample the data. The article shows that a phase-locked loop (PLL) can be used to recover the sample clock. A quick look at the GNURadio API reference made it look like a more involved solution than I was looking for at the time. Knowing the sample rate and the number of samples per symbol, it should be possible to recover the message by post-processing the data file from the previous example (bpsk31_dig_out.raw) using a simple algorithm: sample the data half of a symbol period after a transition or after a full symbol period if no transition occurs. In this case the symbol rate is 31.25Hz and the sample rate is 11025Hz so there are 352.8 samples per symbol.

#!/usr/bin/env python

import re
import struct
import varicode

infile = open('bpsk31_dig_out.raw', mode='rb')

# Initialize the loop
sample_cnt = 0
prev_bit = '0'
bit_stream = ''

# Loop through, sampling 176 samples after each transition
# or after 352 samples if there was no transition.
curr_bit = infile.read(1)
while curr_bit != "":
    curr_bit = infile.read(1)
    if (prev_bit != curr_bit):
        sample_cnt = 0
    else:
        sample_cnt += 1
        if ((sample_cnt - 176) % 352) == 0:
            bit_stream = bit_stream+str(struct.unpack('B', curr_bit)[0])

    prev_bit = curr_bit

print "Bit Stream: ", bit_stream

# Use regular expression to separate the characters
# by splitting on two or more 0s
char_list = re.split('00+', bit_stream)
print "Character List: ", char_list

# Use the dictionary to decode the characters
output_str = ''
for char in char_list:
    if char in varicode.decode:
        output_str = output_str+varicode.decode[char]

print "Message: ", output_str

The terminal output from this program is:

Bit Stream: 10000000000000000000000000000000011101001010010101100
11001001101111110011011100110100101111001011111100100101111100101
01001110011010110011110010011110100111001101111100100111101011001
10111001110110011111100101110010011100111101100110010101001001010
01010110011001001101100101100111010101001011101001001011010011100
10110110010010111101001110110100111111110010111011100101011011001
01101011001101011010011010101100110110111001011011100111010000111
1111111111111111111111111111
Character List:  ['1', '11101', '101', '101011', '11', '1', '1101
11111', '110111', '1101', '101111', '10111111', '1', '1011111', '
10101', '111', '1101011', '1111', '1', '111101', '111', '11011111
', '1', '111101011', '110111', '111011', '111111', '10111', '1', 
'111', '1111011', '11', '10101', '1', '101', '101011', '11', '1',
 '11011', '1011', '111010101', '1011101', '1', '101101', '111', '
1011011', '1', '10111101', '11101101', '11111111', '101110111', '
101011011', '101101011', '110101101', '110101011', '110110111', '
10110111', '11101', '1111111111111111111111111111111']
Message:
the quick brown fox jumps over the lazy dog 1234567890

The message is recovered, more or less. Note the first and last items in the character list. The first entry (‘1’) is an artifact of the initial input to the binary slicer, which will output 1 for all inputs >= 0. The last entry (a long string of ones) occurs because there is about 1 second of unmodulated carrier at the end of the file.

Advertisement

8 thoughts on “GNURadio PSK31 Decoder, Part 1

  1. I am using a later version of gnuradio (version 3.6.1). In it they moved the binary_slicer_fb() function.

    To make your second version of the demod code work, one need to make the following changes.

    change the first import from:
    from gnuradio import gr
    to:
    from gnuradio import gr, digital

    change the line:
    slc = gr.binary_slicer_fb()
    to:
    slc = digital.binary_slicer_fb()

    I hope this helps others who are trying to follow along with your excellent article.
    Thanks

  2. Here’s a version that just uses numpy and scipy instead of gnuradio. I also sped up the bitstream production, significantly:

    import re

    import varicode
    import numpy as np

    from scikits.audiolab import Sndfile
    from scipy.signal import butter, lfilter

    SOURCE = 'bpsk31.wav'
    SYMBOL_RATE = 31.25

    def butter_lowpass_filter(data, cutoff, fs, order):
    nyq = 0.5 * fs
    normal_cutoff = cutoff / nyq
    b, a = butter(order, normal_cutoff, btype='low', analog=False)
    return lfilter(b, a, data)

    f = Sndfile(SOURCE)

    samples_per_symbol = int(round(f.samplerate / SYMBOL_RATE))
    half = samples_per_symbol // 2

    sig = f.read_frames(f.nframes)
    delay = f.samplerate / SYMBOL_RATE
    delayed = np.hstack((np.zeros(delay, dtype='float64'), sig))
    transitions = delayed[:len(sig)]*sig

    filtered = butter_lowpass_filter(transitions, cutoff=600, fs=f.samplerate, order=3)
    digital = np.where(filtered > 0, 1, 0)

    # Sample the stream at half intervals, using the transition points as anchors
    bit_stream = []
    indices, = np.diff(digital).nonzero()

    for i in range(len(indices)-1):
    if indices[i+1] - indices[i] >= half:
    for index in range(indices[i]+half, indices[i+1], samples_per_symbol):
    bit_stream.append('%d' % digital[index])

    bit_stream = ''.join(bit_stream)
    char_list = re.split('00+', bit_stream)

    # Decode the characters
    output_str = ''
    for char in char_list:
    output_str += varicode.decode.get(char, '')

    print 'Message:', output_str

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Twitter picture

You are commenting using your Twitter account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s