Your first receiver : a simple example

In this example we will code a simple narrow band FM receiver working in the UHF PMR band. We will have :

  • A RTLSDR receiver, tuned at 446 MHz, with a sampling rate of 1 MHz,
  • A Digital Down Converter to extract a 12.5 kHz band somewhere in the 1MHz,
  • A FM demodulator with AGC.

First architecture

We will begin with two tasks :

  • A first task controlling the radio and setting it to push IQ samples in a Queue,
  • A second task, reading the IQ Blocks from the queue and doing the DDC and the FM demodulation

The receiver task (second task) receives the following parameters :

  • The Queue name,
  • The frequency offset (where the DDC should be tuned).

The main task code (download here):

// Ask the Soapy interface to search for a RTLSDR compatible device
var rx = Soapy.makeDevice( {'query' : 'driver=rtlsdr' , 'device_name': 'radio'});
// Test that we do have a valid object in return
if( typeof rx != 'object' ) {
 print('no radio ?');
 exit();
}

if( !rx.isValid()) {
 print('no radio ?');
 exit();
}

// set sample rate
if( !rx.setRxSampleRate( 1e6 )) {
  print('Error, cannot change sample rate ?');
  exit();
}
// Set center frequency
var start_frequency = 446 ;
rx.setRxCenterFreq( start_frequency ); // This is in MHz !

// Create a queue to send data 
var queue_name = 'IQ_Data_Queue' ;
var fifo_from_rx = Queues.create(queue_name);

// declare the PMR Channels offsets
var pmr_channels = new Array();
pmr_channels.push( 446.00625 ) ; // channel 1
pmr_channels.push( 446.01875 ) ; // channel 2
pmr_channels.push( 446.03125 ) ; // channel 3
pmr_channels.push( 446.04375 ) ; // channel 3

// Create the consumer task
// We will receive PMR channel 0
var pmr_channel_offset = pmr_channels[0] - start_frequency ;
var id = createTask('fm_receiver.js',queue_name, pmr_channel_offset );

// engage streaming
if( !fifo_from_rx.ReadFromRx( rx ) ) {
    print('Cannot stream from rx');
    exit();
}

waitTask( id );

The fm_receiver.js task code (download here):

// read the queue name from the parameters
var queue_name = argv(0);
// read the channel offset
pmr_channel_offset = parseFloat( argv(1)) * 1e6 ; // convert to Hz for DDC

// access the queue
var fifo_from_rx = Queues.create(queue_name);

// configure the DDC
var ddc = new DDC();  
ddc.setCenter( pmr_channel_offset );
ddc.setOutBandwidth( 12.5e3 );

// configure a FM demodulator and plug it in the DDC
var FMdemod = new NBFM('fm');
FMdemod.configure(  {'modulation_index': 0.2} );
ddc.setDemodulator( FMdemod ); // tells the DDC to call the FMDemod object before output
ddc.setAGC(true);

var IQBlock = new IQData('iq');
// now loop : read IQ block from radio, do something
while( fifo_from_rx.isFromRx() ) {
    if( IQBlock.readFromQueue( fifo_from_rx ) ) {    // load samples from input queue into IQBlock object
        ddc.write( IQBlock);
        var fm_audio = ddc.read(); // output is IQData !!
        // do something with the audio
        // here we just dump data to see if it works
        fm_audio.dump();
    }
}

Download the two files and store them in the same folder. Then, from a Linux command line, do :

/opt/vmbase/sdrvm -f ./fm_rx_master.js

The FM demodulated blocks are received and the .dump() command displays the key informations. You should read something like :

...

(task:1)> IQData object dump:
(task:1)>  name       : no_name
(task:1)>  sample rate: 12500 Hz
(task:1)>  length     : 1639 samples
(task:1)>  Center Frq : 446.006 MHz
(task:1)>  Channels   : 1
(task:1)>  duration   : 0.131 secs. [131.1 msecs]
(task:1)>  GPS status : NO fix
(task:1)>  Attribute  : not set

(task:1)> IQData object dump:
(task:1)>  name       : no_name
(task:1)>  sample rate: 12500 Hz
(task:1)>  length     : 1638 samples
(task:1)>  Center Frq : 446.006 MHz
(task:1)>  Channels   : 1
(task:1)>  duration   : 0.131 secs. [131.0 msecs]
(task:1)>  GPS status : NO fix
(task:1)>  Attribute  : not set
(task:1)> IQData object dump:

...

Add a squelch

We want now to estimate the received signal strength and only process samples if the signal is strong enough. We will add in the receiver a state machine to handle the different states, as follows:

  • Initial state (state=0 in the code) : we estimate the current RMS level (averaged) during 5 seconds , store the value (in fact rms - 5 dB) and then move to state 1,
  • State 1: we wait for the signal to increase at least of threshold value. If this is the case, we move to state 2, otherwise stay in state 1,
  • State 2: we are processing the signal. The transmission may have disappeared, so we check the level. If the level is too low, we move to state 3, otherwise stay in state 2.
  • state 3 : we may have lost the signal, we stay in this state for a maximum of 1 second. After 1 second, if the signal has not increased, then we go back to state 1, otherwise go to state 2

(Download fm_rx_master.js task code)
(Download fm_receiver_squelch.js task code)

The core of the state machine is proposed below :

  • We first add the following variables:
var ts = timestamp();
var rms = -140 ;
var last_rms = -140 ;
var engaged_rms = 0 ;
var threshold = 5 ; 
var state = 0 ; // FSM

and the main loop of the code is now :

        var fm_audio = ddc.read(); // output is IQData !!
        // Average received power (moving average)
        rms = 0.6*rms + 0.4*ddc.getRMS();        
        print( 'rms=' + rms , ' state=' + state ) ;
        switch( state ) {
            case 0: 
                // init - we wait for the RMS level to be stable
                // We leave 5 second for this
                var elapsed = timestamp() - ts ;
                if( elapsed > 5000 ) { // 5000 milliseconds
                    state = 1 ;
                    print('Estimation of background level done, level is ' + rms);
                }

                break ;

            case 1 : 
                // we wait for the signal to increase at least of "threshold"
                if( rms >= (last_rms+threshold)) {
                    // we have a signal increase, engage
                    state = 2 ;
                    engaged_rms = rms - 5;
                    fm_signal_ok = true ;
                    print('We have something');
                }
                break ;

            case 2 :
                // we are receiving, check that the power s
                if( rms < engaged_rms ) {
                    ts = timestamp();
                    state = 3 ; // we may have lost the signal, check later                    
                    print('Looks like signal is lost ?')
                }
                fm_signal_ok = true ;
                break ;

            case 3:
                // we may have lost the signal
                if( rms < engaged_rms ) {
                    // how long ?
                    var elapsed = timestamp() - ts ;
                    if( elapsed > 1000 ) {
                        // lost !
                        print('lost !');
                        state = 1 ; // go back to wait state
                    }
                } else {
                    print('signal is back');
                    fm_signal_ok = true ;
                    state = 2 ;
                }
                break ;          
        }
        last_rms = rms ;
        // do something with the audio
        // here we just dump data to see if it works
        if( fm_signal_ok == true ) {
            fm_audio.dump();
        }

Monitoring all channels simultaneously

We will now implement a "all in one" system where all the channels are monitored simultaneously. For this purpose, we will use a DDCBank system that will allow us to have multiple narrow band receivers from the same radio stream.

  • Our main function will create the DDCBank and allocate sub channels,
  • We will run one task per channel.

The starting task is changed as follows:

// declare the PMR Channels offsets
var pmr_channels = new Array();
pmr_channels.push( 446.00625 ) ; // channel 1
pmr_channels.push( 446.01875 ) ; // channel 2
pmr_channels.push( 446.03125 ) ; // channel 3
pmr_channels.push( 446.04375 ) ; // channel 4

//
var tasks = new Array();

// Create a DDCBank with one channel for each PMR frequency
var multi_ddc = new DDCBank( rx, pmr_channels.length );
for( var i=0 ; i < pmr_channels.length ; i++ ) {
    // compute offset
    var pmr_channel_offset = pmr_channels[i] - start_frequency ;
    // allocate one DDC here, with a 12500 Hz output
    var ddc_channel = multi_ddc.createChannel( 12500 ) ;
    // configure central frequency for this channel
    ddc_channel.setOffset( pmr_channel_offset * 1e6);
    // retrieve the ID so the task will be able to control the DDC
    var channel_id = ddc_channel.getUUID();
    // start the task
    var tid = createTask( 'fm_receiver.js', channel_id );
    // keep the task ID
    tasks.push( tid );    
}

// wait for all tasks to end
while( tasks.length > 0 ) {
    var tid = tasks[0] ;
    waitTask(tid);
    tasks.shift();
}

Each receiver has to pull IQ data from its own DDC. We only provide here a basic code :

var channel_id = argv(0);
var ddc = new DDCBankChannel(channel_id);

// start the DDC receiver
if( ddc.start() == false ) {
    print(' error starting channel id : ' + channel_id );
    exit();
}

for( ; ; ) {
    var iq = ddc.getIQ(true);
    // do something with the IQ data
    iq.dump();
}

(Download fm_multirx_master.js task code)
(Download fm_receiver.js task code)

Last update: February 14, 2022