Create a low-resolution, real-time sound spectrum display
Wow your friends by looking like a less-than-professional sound engineer
I’ve made a few posts about Arduino and LED Matrices. Continuing that series, I wanted to make a project that used all 64 LEDs in the matrix meaningfully, instead of as just a canvas to draw pictures to. This project originally came out of the Discord Status display project, as I wanted to have the matrix display the frequency-spectrum of my speech whenever the mic was enabled. However, this proved to be too much at one time, and so I split the project into two parts. As I began to get the second part working, I found that it might be too processor-intensive to be used practically, so I resorted to using static 8x8 binary “images” as my display for the project. However, I still did manage to get the spectral display to work, and so I’ve decided to share it.
- Computer1 with Python 3 and the following packages:
- Arduino Uno
- MAX7219 Dot Matrix Module ($1.31 USD on AliExpress)
The complete code can be found here.
From the completed code repository, download the file
led_matrix.py and place it in a new folder. This is the code that will allow us to talk to the
matrix. I’ve already discussed how I wrote the driver, which can be found here.
Now, create another file called
spectrum.py. Here is where we will do all the dirty work. In order for this project to all make
some kind of sense, we’re not just going to grab data, FFT it, and blast it off to the Arduino. Rather, we’re going to use
matplotlib to display several figures of data at various stages of processing, all in real time. First, we’ll show the raw microphone
input (time-domain). Next, we’ll show the spectrum of the data in the frequency domain (after we apply a windowing function
to boost the human vocal frequencies). Finally, we’ll graph the spectrum, decimated and downsampled, “discretizing” the y values to fit on the 8x8 display.
import threading from math import ceil from time import sleep import matplotlib.pyplot as plt import numpy as np import pyaudio from pyfirmata import Arduino from scipy.signal import butter, sosfiltfilt, decimate import led_matrix global keep_going # will use to start and stop the "animation" class SpectrumPlotter: def __init__(self): self.init_plot() self.init_mic() self.init_matrix() self.annotation_list = 
We initialize the plot, microphone, then matrix (in that order). We also keep an annotation list, which we’ll use to draw column-numbers on the values of the final spectrum (making debugging the hardware a little easier).
def init_plot(self): print('Initializing plot...') _, self.ax = plt.subplots(3) # Prepare the Plotting Environment with random starting values x1 = np.arange(10000) y1 = np.random.randn(10000) x2 = np.arange(8) y2 = np.random.randn(8) # Plot 0 is for raw audio data self.li, = self.ax.plot(x1, y1) self.ax.set_xlim(0, 1000) self.ax.set_ylim(-5000, 5000) self.ax.set_title("Raw Audio Signal") # Plot 1 is for the FFT of the audio self.li2, = self.ax.plot(x1, y1) self.ax.set_xlim(0, 2000) self.ax.set_ylim(0, 1000000) self.ax.set_title("Fast Fourier Transform") # Plot 2 is for the binned FFT self.li3 = self.ax.plot(x2, y2, 'ro') # for some reason, returned as a list of 1 self.ax.set_xlim(0, 7) self.ax.set_ylim(0, 7) self.ax.set_title("8-Binned FFT") # Show the plot, but without blocking updates plt.pause(0.01) plt.tight_layout() print('Done') def init_mic(self): print('Initializing mic...') FORMAT = pyaudio.paInt16 # We use 16bit format per sample CHANNELS = 1 self.RATE = 44100 // 2 # sample rate self.CHUNK = 1024 # size of the frame self.audio = pyaudio.PyAudio() # start Recording self.stream = self.audio.open(format=FORMAT, channels=CHANNELS, rate=self.RATE, input=True) global keep_going keep_going = True print('Done') def init_matrix(self): print('Initializing Arduino...') self.board = Arduino('COM3') print('Done') self.matrix = led_matrix.LedMatrix(self.board) self.matrix.setup()
Now it’s time to actually start seeing some data. We’ll write a
process_data function, which does most of the work.
It performs the bandpass windowing function on the data, isolating the human-voice frequencies used in telephony (300Hz-3400Hz).
It then takes the real-valued Fast Fourier Transform of the filtered data, converting it to a frequency-domain spectrum.
Finally, it adds the data to the 3 plots, providing a discretized spectrum in the form of the 3rd plot.
However, we’ll need to define the bandpass-filter and downsampling functions first.
def butter_bandpass(lowcut, highcut, fs, order=5): nyq = 0.5 * fs low = lowcut / nyq high = highcut / nyq sos = butter(order, [low, high], analog=False, btype='bandpass', output='sos') return sos def butter_bandpass_filter(data, lowcut, highcut, fs, order=5): sos = butter_bandpass(lowcut, highcut, fs, order=order) y = sosfiltfilt(sos, data) return y
butter_bandpass function simply generates the filter coefficients. We use
it can handle higher orders without giving strange negative results (like the
ba filter does).
butter_bandpass_filter, we take the cutoff frequencies of the pass-band, generate the coefficients on the fly, and then return the filtered data. Discretizing the plot involves first decimating the spectrum along the x-axis (frequency). Since we want the number of samples to be
xbins, our decimation factor is determined by dividing the length of the input data by the number of xbins we want (which will be 8). We then use a list comprehension to scale every y-value, from the range [0,
maxval] (float) to [0,
ybins] (int), and return that list.
def discretize_plot(data, xbins, ybins, maxval): downsample = decimate(data, int(ceil(len(data) / xbins)), zero_phase=True) return [int((val / maxval) * ybins) for val in downsample]
With that out of the way, we can finally implement our
In a nutshell, the
- Performs the necessary DSP functions on the current audio frame.
- Draws the data to the 3 debug graphs
- Updates annotations on the “LED” graph (#3)
def process_data(self, in_data): # get and convert the data to float audio_data = np.fromstring(in_data, np.int16) # apply band-pass to amplify human speech range audio_data = butter_bandpass_filter(audio_data, 300, 3400, self.RATE, 20) # Fast Fourier Transform, 10*abs to scale it up and make sure it's all positive dfft = 10*abs(np.fft.rfft(audio_data))[:300] # Force the new data into the plot, but without redrawing axes. # If uses plt.draw(), axes are re-drawn every time self.li.set_xdata(np.arange(len(audio_data))) self.li.set_ydata(audio_data) self.li2.set_xdata(np.arange(len(dfft)) * 10.) self.li2.set_ydata(dfft) self.li3.set_xdata(np.arange(8)) self.li3.set_ydata(discretize_plot(dfft, 8, 8, 1000000)) # Refresh annotations for the "LED" graph for a in self.annotation_list: a.remove() self.annotation_list.remove(a) for i, txt in enumerate(self.li3.get_ydata()): self.annotation_list.append( self.ax.annotate(str(txt), (self.li3.get_xdata()[i], self.li3.get_ydata()[i]))) # Show the updated plot, but without blocking plt.pause(1 / 30) return keep_going
Finally, we’ll define a function that starts the processing, and writes the contents of the final graph out to the LED Matrix. We open up the microphone stream so we can begin reading data.
update_matrix is defined as a periodic callback that sends data to the Arduino in a background thread.
process_data is then called in a loop, which continuously pulls from the microphone stream and processes the data, as discussed earlier.
self.CHUNK is the number of samples per frame.
def start_listening(self): global keep_going # Open the connection and start streaming the data self.stream.start_stream() print("\n+---------------------------------+") print("| Press Ctrl+C to Break Recording |") print("+---------------------------------+\n") def update_matrix(): while True: for col, val in enumerate(reversed(self.li3.get_ydata())): self.matrix.maxAll(int(col + 1), int((2 * pow(2, val - 1)) - 1)) sleep(1 / 30) threading.Thread(target=update_matrix, daemon=True).start() # Loop so program doesn't end while the stream callback's # itself for new data while keep_going: try: self.process_data(self.stream.read(self.CHUNK)) except KeyboardInterrupt: keep_going = False # Close up shop (currently not used because KeyboardInterrupt # is the only way to close) self.stream.stop_stream() self.stream.close() self.audio.terminate()
Finally, here’s the
main statement to get the code moving:
if __name__ == "__main__": p = SpectrumPlotter() p.start_listening()
Currently, as there are several different threads going on (processing, plotting, arduino) that neither
Ctrl+C nor closing the plot window will close the program. It has to be manually killed via the Stop button in your IDE or through the Task Manager. This project was a merely an experiment, trying to show how PyFirmata (and my LedMatrix module) can be used to extend the capabilities of both the Arduino and the PC. The Arduino can’t do real time signal processing of this magnitude, and the PC doesn’t have native support for an 8x8 display. On a more abstract note, we’re combining high-level programming techniques and modular advantages of embedded systems to create something new and fun3. Since the MAX7219 matrices can be daisy-chained, it’s possibly to build an 8x8n-1 matrix, where
n = # of matrices in the chain. This would allow higher resolution of the frequencies, as the decimation factor would decrease. If I get more matrices, I’d love to explore this further.
1I have only tested on PC (Win10), but this should theoretically work on Linux/OSX, although you may have to change the
'COM3' to however your system names serial ports.
sos stands for Second-order sections, which you can read about more here.
3If you think this is fun, you must be a weird nerd.