Hands-on Digital transmission with your soundcard

This article is part of the fundamentals of my real-world tutorial on digital communications using a cheap soundcard as the radio. If this notebook is interesting to you, check out the full tutorial!

03 - Baseband signal transmission

In the previous notebook we introduced a streaming object for simulating the audio channel. In this notebook, we will generate a baseband signal and observe the received signal from the audio channel and simulated channels.

We will use the linear real-valued modulation given by

$$ x(t) = \sum_{m=-\infty}^{\infty} d[m]g(t-mT) $$

where $d[m]$ is the BPSK-modulated bitstream, i.e. $$ d[m] = \begin{cases} 1 & b[m] = 0 \\ -1 & b[m] = 1\end{cases} $$ and $b[m]$ is the bit to be transmitted at the $m$th symbol. Moreover, $g(t)$ is the transmit filter and $T$ is the symbol rate. For more detailed description of above equations, refer for example to DSPIllustrations.com - The First Nyquist Criterion and DSPIllustrations.com - Eye Diagram Examples.

First, let's get the standard stuff imported:

In[20]:
import matplotlib
import matplotlib.pyplot as plt
%matplotlib ipympl

%load_ext autoreload
%autoreload 2

import numpy as np
import sys; sys.path.append('..')
The autoreload extension is already loaded. To reload it, use:
  %reload_ext autoreload
In[2]:
try:
    %load_ext tikzmagic
except ModuleNotFoundError:
    print ("Did not find tikzmagic. You will not be able to compile the tex code!")
In[3]:
from audioComms import Environment
from audioComms.plotting import PlotSpectrum, PlotWaveform

First, let's define some filters:

In[4]:
# Source code has been redacted in online version
# Omitting 16 lines of source code

Try out these filters by plotting them:

In[5]:
# Source code has been redacted in online version
# Omitting 7 lines of source code

Now, let's define a transmitter that generates the according baseband signal. Here, we make use of the library function StatefulFilter which implements a linear filter which keeps the state between subsequent calls to its filter function:

In[6]:
from audioComms import TX
from audioComms.utils import StatefulFilter
In[7]:
class TransmitSignal(TX):
    def __init__(self, environment, g, Ts):
        """g contains the impulse response of the filter. Ts equals the symbol duration in seconds"""
        super().__init__(environment)
        self._filter = StatefulFilter(g, [1])
        self._Ts = Ts
        self._samplerate = environment._samplerate
        
    def _generateSignal(self):
        nBits = 50
        bits = np.random.randn(nBits) > 0
        data = 1-2*bits
        
        Ns = int(self._samplerate*self._Ts)
        dataUps = np.zeros(nBits*Ns)
        dataUps[::Ns] = data
        
        x = self._filter.filter(dataUps)
        return x

Let's try out this signal generator:

In[8]:
# Source code has been redacted in online version
# Omitting 11 lines of source code

Now, let's transmit this signal through the channel and plot the eye diagram. To plot the diagram, we use the library object PlotEyeDiagram. In a block diagram, the chain looks as follows:

In[9]:
%%tikz -l positioning, --size=800,240
\tikzset{block/.style={draw,thick,minimum width=2cm,minimum height=1cm}}
\tikzset{new/.style={fill=red!50!white}}

\node (T) [block,new]  {TransmitSignal};
\node (C) [block,right=of T] {Channel};
\node (E) [block,right=of C,new] {Plot Eye diagram};



\draw [-stealth] (T) -- (C);
\draw [-stealth] (C) -- (E);
No description has been provided for this image
In[10]:
from audioComms.channels import AudioChannel, SimulatedChannel, IdentityChannelEffect
from audioComms.plotting import PlotEyeDiagram
In[11]:
# Source code has been redacted in online version
# Omitting 15 lines of source code
In[12]:
runTransmission('rc', lambda env: SimulatedChannel(environment=env, channelEffect=IdentityChannelEffect(gain=0.2)))
Using Audio device default

Apparently, we see a perfect eye diagram with the simulated channel. Now, let's try this with the real audio channel:

In[13]:
runTransmission('rc', lambda env: AudioChannel(env))

Unfortunately, the eye pattern is not visible at all. How can that be? Didn't we see in the first notebook that the sound device accurately transmits the signal? Let's make some additional test, by using a rectangular filter for the pulse shaping. First, we run it in the simulated channel:

In[14]:
runTransmission('rect', lambda env: SimulatedChannel(env,channelEffect=IdentityChannelEffect(gain=0.2)))
Using Audio device default

The simulated channel shows the perfect eye diagram. Let's compare against the eye diagram from the audio channel:

In[15]:
runTransmission('rect', lambda env: AudioChannel(env))

Uh, oh, what's happening there? Where are the rects gone? Instead, it more looks like a bunch of RC discharging curves. How can that be? Remember, the RC circuit acts as a lowpass. Also, what's usually written in the frequency range of audio devices? Something like 20-20000Hz. Ah, the sondcard acts like a highpass for our signal, maybe with cutoff 20Hz. Let's try to replicate this behaviour with a simulation. We just add a highpass to the channel output (with RC Transfer function):

In[16]:
from audioComms.channels import HighpassChannelEffect
runTransmission('rect', lambda env: SimulatedChannel(env, channelEffect=HighpassChannelEffect(gain=0.1)))

Suddenly, the simulated eye diagram nicely matches the audio channel. Note, that depending on your audio device, the filter would need to be adapted to match the audio channel.

Apparently, our signals all have significant contribution in the low frequency region. Hence, they are not suitable for the baseband transmission with the soundcard, as we do not get a clear eye diagram. How can this issue be resolved? There are some possibilties:

Before we actually overcome the problem of the highpass character of the audio device, let's measure the audio behaviour around DC. We create a transmitter that transmits pure Gaussian noise samples. At the receiver we plot the spectrum:

In[17]:
# Source code has been redacted in online version
# Omitting 18 lines of source code

First, let's plot the spectrum of the received signal over the sound card. This measurement is best run with a cable between audio output and microphone input.

In[18]:
runNoiseTransmission(lambda env: AudioChannel(env))
Using Audio device default
Stream Stopped 1
Stream Stopped 2

Then, let's plot the signal when sent over the simulated channel:

In[19]:
runNoiseTransmission(lambda env: SimulatedChannel(env, channelEffect=HighpassChannelEffect(gain=0.2)))

Looking at both spectra, it appears that the simple lowpass filter seems to quite accurately match the real sound card.

Outlook

In the following notebook, we will upconvert our transmit signal to passband. In addition to the improved frequency response of the soundcard in the passband, this technique has another advantage: It allows us to move the signal into frequencies we can barely here, significantly attenuating the transmission sound. Even though it's interesting to hear the signal, it can quickly become annoying for you and your neighbors.

This article is part of the fundamentals of my real-world tutorial on digital communications using a cheap soundcard as the radio. If this notebook is interesting to you, check out the full tutorial!

Copyright (C) 2025 - dspillustrations.com


DSPIllustrations.com is a participant in the Amazon Services LLC Associates Program, an affiliate advertising program designed to provide a means for sites to earn advertising fees by advertising and linking to amazon.com, amazon.de, amazon.co.uk, amazon.it.