Frequency Reactive LEDs, i2c Protocol, Raspberry Pi, and Other Adventures.
Here at Pix Incorporated we take word count very seriously. We believe in short, focused, and enjoyable writing which hints at the horizons of our imagination (rather than a 7000 word datasheet induced concussion that could just as easily have been written by the 2011 Words With Friends AI).
That’s why, unlike last time, Pixmusix’s Marketing & Communications Department is committed to digestible content that boasts under one thousand words. To hold myself accountable let’s keep track of the word count.
What we are working towards
80/1000 : The story so far…
When I was starting out on this hobby I would buy something that looked interesting and play around with it to see what I’d learn. One victim of my ‘pretty thing consumption spells’ was this MsgEq7 which I bought, received, put away, and promptly ignored for twelve months.
Earlier this year a regular from the CE forum’s reached out to me asking how I’d approach making a 3 band frequency reactive LED setup for his home stereo. We chatted through software solutions (we found something called Goertzel Analysis which looked cool). When we gave up on that we began looking into analog filters. Eventually, we had to put the conversation on pause because he got caught up in a larger project.
More months meander and then, about 6 weeks ago having finished a synth project, I dug up my collection of “I’ll get to that” chips for the next idea that will itch my fusible metal alloy addiction. The MsgEq7 Spectral Analyser jumped out as our solution to frequency reactive LEDs. Every transistor has its day (except the ones I accidentally burn up) so I pulled out the MsgEq7, snapped it on a breadboard, and pulled up the datasheet.
285/1000 : What is an MsgEq7?
The MsgEq7 is a spectral analyser. That means it takes in some audio, like music, and it returns the strength* of the frequencies that make up that music. Music is, afterall, just a bunch of frequencies mashed up in such a way that we feel things.
How does that work on a lower level? Great question, but your guess is as good as the datasheet’s which provides a brief explanation of the chip's purpose, a timing diagram, an example schematic, and then chucks a ‘Bob Menzies’ and abruptly resigns.
Not to say that the datasheet is useless. The description and this diagram tells that the IC parses the audio signal into sharp bands and the peaks of those bands are multiplexed. It has the pinouts and typical operation.
To learn more I figured out who manufactured the chip and tracked down the website which has a 2005 myspace vibe to it which I find wholesome and nostalgic. There wasn’t any additional information on the website so I found their contact page and reached out to them.
They confirmed that the MsgEq7 uses analogue filters with rc peak detection and no RMS. That’s expected. I’m still in dialogue but it is, understandably, taking some time. Any further information I’ll put in the comments.
577/1000 : Chapter 1 : Glowbit Rainbow Spectroscope
I’m hoping the reader has experience with a programming language (doesn’t matter which one). I’ll break down the code excerpts so as long as you’re comfortable with things like functions, data types, and classes you’ll get the gist. I’m also assuming you have some electronics basics i.e. resistors, logic levels, schematics, etc. If you have any questions you can always ask in the comments. If you need help porting to your programming language of choice I’m happy to assist.
607/1000 : Timing Diagrams
I like starting with timing diagrams when approaching a new chip. To connect to the MsgEq7 it looks like we just need two inputs: something that’ll toggle and something to reset it. We will also need something to read the output which is an analogue signal. That’s three I/O total so let’s choose a tool to do that.
Choosing our I/O
I decided I wanted to use a microcontroller to interface with the MsgEq7 and I chose the ATtiny85 Microprocessor. I like The Tiny and I have lots of them lying around for simple tasks like this. They are NOT easy to work with so I’ve also tested:
- The Adafruit Qt Py RP2040 which was a lovely experience.
- The Arduino Micro: old faithful.
You could use any microcontroller you’re comfortable with, as long as it has:
- More than 8mhz clock speed
- At least 2 digital pins
- At least one analogue pin
- An i2c bus.
The code won’t be complex so I’m going to program it with Arduino IDE because it’s very accessible.
Modelling the timing diagram
Notice that the timing diagram has two sections: Firstly, there is this initialisation stage right up until output setting time. Secondly we have an output loop where the multiplexor spews filter values. After ‘trs’ we can ground the strobe and the multiplexer punches the first band to the output. From there it’s all “second verse, same as the first”.
Initialization Code
So let’s start by modelling the timing diagram’s initialisation stage in our microcontroller. At the beginning of the code I’ve written up a MsgEq7 data-type which has necessary I/O and the index to keep track of which filter the multiplexor is ready to output. See also a function called reset() which you can follow through and convince yourself it matches initialization in the timing diagram above. Last, I’ve got a function called makeMsgEq7() that will populate and return a new instance of an MsgEq7 type.
struct MsgEq7 { static const int num_bands = 7; int index; // filter to be output from multiplexor int lastIndex; // filter that was output from multiplexor byte pinStrobe; // output : multiplexor control byte pinReset; // multiplexor reset byte pinReader; // filter data from multiplexor void reset() { strobeDown(); digitalWrite(pinReset, HIGH); // reset delayMicroseconds(1); // tr: reset pulse width digitalWrite(pinReset, LOW); delayMicroseconds(10); // just good manners strobeUp(); // arm the strobe delayMicroseconds(72); // trs: reset to strobe index = 0; // set multiplexor pos } /* ... */ } MsgEq7 makeMsgEq7(byte rdd, byte str, byte rst) { MsgEq7 eq; // new unpopulated data type /* necessary IO */ eq.pinStrobe = str; eq.pinReset = rst; eq.pinReader = rdd; pinMode(eq.pinStrobe, OUTPUT); pinMode(eq.pinReset, OUTPUT); pinMode(eq.pinReader, INPUT); eq.reset(); // init MsgEq7 Datatype return eq; }
Now we can use our makeMsgEq7() function to construct a model of our IC. This new object we just made, eq, represents an MsgEq7 IC that is ready to start outputting spectral data.
// init step MsgEq7 eq = makeMsgEq7(READ_EQ_PIN, STROBE_EQ_PIN, EQ_RESET_PIN);
Output Loop Code <- Requires Revision
So let’s start by modelling the timing diagram’s initialisation stage in our microcontroller. At the beginning of the code I’ve written up a MsgEq7 data-type which has necessary I/O and the index to keep track of which filter the multiplexor is ready to output. See also a function called reset() which you can follow through and convince yourself it matches initialization in the timing diagram above. Last, I’ve got a function called makeMsgEq7() that will populate and return a new instance of an MsgEq7 type.
int readNextBand() { strobeDown(); // request and output int w = getVal(); // Get band from multiplexor strobeUp(); // index increments return w; }
If we put this function in a loop() we will have fully modeled the timing diagram.
// output step void loop() { int reading = eq.readNextBand(); }
1138/1000 : Breadboarding the MsgEq7
We have a microcontroller with some code that we think would interface with the MsgEq7. We better throw them both on a breadboard and see what happens. Let’s look at the schematic from the datasheet
Schematic
Audio Inputs
Good time to pause and ask how we are going to get audio into our circuit. I’ve tested two methods.
Microphone
This Adafruit microphone does work, but it’s a little quiet with the circuit recommended by the datasheet. I swapped out the 22k resistor on pin 5 for a 5k resistor and tuned the internal amplifier inside the breakout.
Jack
Second, and what I settled on, is a line-level input. I used a breadboardable TRS 3.5mm jack input.
This allowed me to pull audio from my laptop headphone jacks, my synths, my pro-audio equipment, etc.
Perhaps you would like to use a Bluetooth receiver or maybe even the output of a Raspberry Pi media kiosk.
Notes on audio input
When using my laptop/phone headphone jacks I found I needed the volume at full blast. I often threw a preamplifier in the signal chain (and I would recommend that). If you do use a preamplifier try to ride its volume output knob and not its gain. This will avoid clipping which protects the hardware and gives us better data.
The MsgEq7 is fairly friendly with various audio sources. I’m not the first champ to use it so you’ll find lots of input solutions online. If you are feeling unsure here feel free to comment below and I, or someone in the community, will I’m sure be happy to help.
Schematic
1416/1000 : Does it work?
So far we’ve written some code that just blindly follows the timing diagram and built the default schematic from the datasheet. Now feels like a good place to actually test our work.
To test our work we’re going to need an output as feedback and I’ve chosen CE’s glowbit Rainbow. The Rainbow is 13 digitally controllable full-colour LEDs. It’s already quite happy at 3.3v because it’s designed with the Micro:Bit in mind.
The rainbow has two data lines, one IN and one OUT. We only care about writing data so we connect our microcontroller to the rainbow’s data-in. My microcontroller has two pins available and I’m choosing pin 5 to be the data pin for the rainbow.
We need to write a little more code before we can test our circuit. The rainbow is a WS2812B device which means the LEDs are chained together in such a way you can control them with a protocol. We don’t want to write this protocol from scratch which means we need to “take inspiration” from someone else’s code.
FastLED Library
FastLED is a pretty common way to drive WS2812B LEDs from within the Arduino ecosystem and it’s easy to install from within Arduino IDE.
To start using FastLED in our code we need to include it in our project and instantiate an array of its CRGB objects. A CRGB represents a single LED in our WS2812B chain. Because our rainbow has 13 LEDs we need 13 CRGBs in our array.
#includeCRGB leds[13];
Next it wants us to add our array of CRGB to a provided template class called FastLED which acts like a driver/runtime. The constructor takes our array and its size as arguments. The template itself needs the protocol and the pinout so it can customize the class appropriately to our needs.
pinMode(LED_DATA_PIN, OUTPUT); FastLED.addLeds(leds, NUM_LEDS);
Now that we have an instance of FastLED, a typical use is to first modify the CRGB[] collection, then second, call FastLED.show() which reads our modification through a reference and handles punching out the ones and zeros to our rainbow.
/* cylon the Leds as feedback that rainbow is working */ int pos = NUM_LEDS * (-1); while (pos <= NUM_LEDS) { FastLED.clear(true); int k = NUM_LEDS - abs(pos); // the index we want to modify leds[k] = CRGB(0,25,0); // mod the array at k to green FastLED.show(); // punch out the data to rainbow delay(250); pos += 1 }
Using the MsgEq7 object to control our CRGB array
To test if our MsgEq7 code works we can set the brightness of our LEDs proportionally to our filter readings. We will make a new function in our MsgEq7 struct called readBandAt(int target) which will strobe to and read a specific index of the multiplexor. We can use this to map our MsgEq7’s spectral bands to a corresponding LED on our rainbow.
struct MsgEq7 { /* ... */ int index; /* ... */ int readBandAt(int target) { target = target % num_bands; // better be 0-6 or I'm telling mum strobeTo(target); // get to the band we care about return readNextBand(); // give them at band } /* ... */ void strobeTo(int target) { target = target % num_bands; while(index != target) { // usually we would be there already strobeUp(); // if not let's strobe without reading strobeDown(); // recall strobeDown() increments index } } /* ... */ } /* ... */ void loop() { if (eq.num_bands > NUM_LEDS) { while(true){} } // must be this tall to ride for (int j = 0; j < eq.num_bands; j++) { int spectralData = eq.readBandAt(j); // this will be a 10 bit value spectralData = map(spectralData, 0, 1024, 0, 256) // FastLED colours are 8 bits CRGB colour = CRGB(0, 0, spectralReading); // higher spectralData = more blue leds[j] = colour; // assign it to an led } fastLED.show(); // show me my audio spectrum }
Audio to test with
We need some audio that will give us predictable and repeatable results. On my GitHub repository for this project, I have a folder with some good audio for testing. I’ve recorded some sine waves which correspond to the frequencies listed on the MsgEq7 datasheet (i.e. 63Hz, 160Hz, 400Hz, 1kHz, 2.5kHz, 6.25kHz, and 16kHz). I’ve also recorded a few seconds of a sine sweep so you can see some motion. Lastly, you’ll find a simple drum kit loop which looks pretty on the glow bit. All of this audio is open source under the GitHub license so you are free to download it and use it for your tests.
2164/1000 : It works! Let’s wrap this up.
Good to see success but it looks a little unfinished. Starting with the obvious: it would be nice if we could fill all 13 LEDs with spectral reactive colour. Sadly the MsgEq7 only has seven bands and the Pixmusix Advanced Mathematics Department has informed me that 13 is bigger than 7.
To solve this, we can use interpolation to “stretch” our 7 bands across our 13 LEDs. It’s not too dissimilar to how photo editing suites upscale a jpeg. Below is a desmos example. You can play the graph yourself here.
We should code up the interpolation mathematics from my desmos graph in C++. Let’s make an array called ledLum[13] which will store our interpolated spectrum. Then we can map our interpolated spectrum to the brightness of the red channel of our rainbow.
void loop() { /* Populate eq.bands with new data */ do { eq.readNextBand(); } while (eq.index != 0); /* holds our interpolated data */ double ledLum[NUM_LEDS]; for (int i=0; i < NUM_LEDS; i++) { /* where to sample from */ const int idx = i * (eq.num_bands - 1) / NUM_LEDS; /* where to write our sample to */ const int mod = i * (eq.num_bands - 1) % NUM_LEDS; /* interpolation */ const double q = double(mod) / double(NUM_LEDS); ledLum[i] = eq.bands[idx] * (1.0-q) + eq.bands[idx+1] * q; /* convert from 10bit -> 8bit data */ ledLum[i] = ledLum[i] / 1024.0 * 255.0; /* map the luminosity to a colour on our rainbow */ leds[i] = CRGB(ledLum[i], 2, 25); } /* bit bang the colours to the hardware */ FastLED.show(); }
Our LED Spectroscope!
Upload that code and all our LEDs will dance to our music. Looks great!
If you’d like to make your own you can get the full code from the GitHub repository below. ????????
Job Done!! Enjoy. ????
2476/1000: Except… maybe we could do something new?
If you search online you can find twenty or thirty articles all showing you how to light up ‘this’ or ‘that’ with an MsgEq7. I feel like I’ve brought some extra detail to the table which is nice and all, however, I want to bring this to another level.
Big dreams need more power!
I want a fair bit of computational grunt and the pleasantries of a Linux environment. Easy tooling and RAM will put more creative options on the table. My microcontroller just isn’t going to cut it.
My Raspberry PI 400 would work and it has some I/O, but unfortunately, it only has digital pins so it can’t read analogue signals. We need to be able to read the analogue output of the MsgEq7s multiplexor.
Serialize into i2c
Although, the Raspberry Pi does have something we could use: i2c buses. Maybe, instead of reading the audio peaks directly, we serialize the MsgEq7 outputs with our microcontroller. Then we transmit the serialized data to the Raspberry Pi via i2c. Oh my! It would seem that the two remaining pins on my ATtiny happen to be the Serial Data and Serial Clock! Just what I need to transmit i2c data.
A warning ⚠️
Up till now in this article I’ve been recounting the required steps to get some spectral reactive LEDs, only pausing occasionally to explain the code. From here, the article is not a recipe. Instead it will more closely follow an abbreviated version of my personal creative journey (including my mistakes).
2739/1000: Chapter 2. Making an i2c Spectrum Analyser
What is i2c?
Quick refresher. I2c is a two-wire communications protocol. One wire is for the data; the other wire is a clock. We bring the clock low, prepare the next bit on the data line, and then release the clock HIGH to sample that data. So far so easy.
I2c is also addressable meaning we can talk to multiple peripherals at the same time. The peripherals are called slave devices which are given their own unique address on the bus. A single master device uses this address to talk to all of them. You can see below a timing diagram from Sparkfun’s excellent article on i2c which I think is illustrative of protocol. Notice that the default logic level of the data and the clock is HIGH because we will come back to that.
Next steps
That’s probably enough theory to get us on track. What we need to take away from this refresher is that we are making a MsgEq7 peripheral, which means we are a slave device. We need to modify our software and implement some behavior to handle requests from a master device. We’re also going to need some additional hardware on the board to allow master devices to connect in.
2954/1000 : Modifying our hardware
Let’s think about how our master device will connect to the audio-spectral peripheral we are making. If you search up some other i2c devices they all use a jst:sh connector. Adafruit calls them stemma; Sparkfun calls them qwiic. They all provide four pins: 3.3v, GND, SCL, and SDA. That’s nice because it allows our master device to power our board as well as connecting it to the i2c bus. CE’s piicodev line uses these jst:sh connectors too and they make this breadboard adapter which is perfect for our needs.
Adding a Piicodev connector to our breadboard
Note: The microcontroller I chose, the ATtiny85, uses pin 5 for i2c Serial Data (SDA) and pin 7 for i2c Serial Clock (SCK). Your microcontroller may use different pins which you can locate with a quick online search.
Up till now, I’ve been keeping it flexy on a breadboard but this is the circuit I’d like to settle on, so I’m going to take this opportunity to solder everything in place.
Schematic
3137/1000 : Implementing i2c in our software
To start, we will remove the rainbow and scrap all the FastLED library code. We also don’t need any of the interpolation code because we would expect the Master device interfacing with us to handle stuff like that. Our job is to read the MsgEq7 and just transmit that reading.
Our first task will be implementing that i2c protocol but no glass of champagne could be worth implementing it all from scratch so like a wild pig’s oath to a kayak we’re gonna borrow code again.
Wire library
Arduino has written a native i2c library for their IDE which will work fine for us. If, like me, you’re using the Attiny85 it unfortunately can’t handle Wire.h. Thankfully this champion has uploaded a library called TinyWireS that works like a charm ????; just substitute TinyWireS in place.
#include//Works on most /* OR */ #include //for ATtiny85
The official docs list out all the functions the API has to offer, the first of which is called begin() which feels like a good place to start.
Joining the bus
begin() is for joining the i2c bus. The function takes an optional param and there is a note on the docs that says “the 7-bit slave address (optional); if not specified, joins the bus as a controller device.” We are making a slave peripheral so the docs are telling us that we need to declare that by selecting our address on the bus and passing that as an argument when we call begin(). Address 8 is a common choice for custom peripherals but you can pick basically anything.
void setup() { Wire.begin(0x08); /* [ ... ] */ }
Sending data
Back to the list of API functions, the next one that catches my eye is requestFrom(). Reading through, it looks like the master device calls this function whenever it wants data from us. First, we had better decide what data we want to send back. My first thought is to just send the output of the next band from our MsgEq7.
void requestEvent() { Wire.write(eq.readNextBand()); // Bad Code!! }
ERROR ⚠️
Oh! That didn’t work.
It didn’t work because the Wire.write() command only* accepts an 8bit value and eq.readNextBand() returns an integer between 0-1023 which is a 10bit value (stored as a 32bit int). That’s a problem we are going to have to solve.
3535/1000 : Packaging our data
There is an easy fix here, we could just map our 10bit bandpass reading into 8bits.
Byte eightBitBandpassReading = map(eq.readNextBand(), 0, 1024, 0, 255);
But notice that we are losing precision here because the C++ byte is an unsigned integer meaning we can’t store decimal points. It’s a bit of a shame to be spending all this time passing our MsgEq7 data to a more powerful machine only to have lost accuracy on the way.
To be honest, just sending the value of the MsgEq7 Band was a terrible idea anyway. There are seven bandpass filters in the MsgEq7 and they all correspond to different center frequencies.
“Receiving the peak value of a bandpass filter is only useful if we know WHICH band pass filter we received so we can determine its center frequency”
We could try and wrangle the master device to keep track of which band it is about to read. That seems silly because the microcontroller on the peripheral already has the index stored inside the MsgEq7 data-type we made.
What I should do is ask my AtTiny85 to package up both the multiplexor’s index and the filter reading, transmitting them together when the master device requests data. That way, when the master device gets data from our MsgEq7 peripheral, it will also get which of the seven filters that reading corresponds to.
Syntax of our outgoing message
How are we going to package up our message? We need at least 3 bits to encode each position on the multiplexor because there are seven possible multiplexor indexes. That means we need to transmit at least 13 bits, 10bits for the filter reading and 3bits the frequency it represents. We can easily squeeze that into a 16 bit word. We will have a few wasted bits in our transmissions but design is full of compromises.
There are lots of ways we could construct the syntax for our 16bit transmission, but this is what I came up with. The first 4 bits hold our index and the next 12 hold our value. We’re rocking ‘little endian’ which means I’m sending the LSB of the value first followed by the MSB (with the index).
3905/1000 : Writing a function that will send a message.
Back on track! We’re trying to write a function that runs when a master device requests data from our MsgEq7 peripheral. I came up with the below.
void writeToi2c(int idx, int val) { byte lsb = val & 0xff; // construct the first byte to transmit byte msb = (val >> 8) | (idx << 4); // construct the second byte to transmit /* dispatch in little endian */ Wire.write(lsb); Wire.write(msb); } void requestEvent() { int idx = eq.index; // get the index we're about to read int val = eq.readNextBand(); writeToi2c(idx, val); }
For our first byte, we got the LSB of our value with a bitwise and operation against 0b11111111. Recall that any bit &’d with a 1 will always equal itself. If for example our filter’s value was 0b1100101110:
0011 0010 1110 // value 0000 1111 1111 & // 0xff ------------------------ 0000 0010 1110 // LSB!
For our second byte, we now need to synthesize the two top bits from our value with the index. We can pull the MSBs to the end of our second byte with eight shift rights: just where we want them. Then we can shift our index to the top of our byte with 4 shift lefts. Lastly, to bring it all together we can bitwise or. Recall that any bit |’d with a 0 will always equal itself. Let’s step through that too:
0011 0010 1110 // value >>>> >>>> // eight ror 0000 0000 0011 // just the two MSB remains in right spot 0000 0000 0101 // multiplexor index is 5 <<<< // four rol 0000 0101 0000 // index in the right place 0000 0000 0011 // shifted MSB 0000 0101 0000 | // shifted index ------------------------ 0000 0101 0011 // Index and value’s MSB fused with bitwise or op
We can send these one after the other with Wire.write(byte k) and that completes our transmission word.
4208/1000 : Interrupts!
In order to ensure our requestEvent() function runs when the master device requests data, the Wire library wants us to assign it to an interrupt. Wire is kind enough to provide us an API to do this too and it’s called onRequest(). Notice that we are not putting brackets at the end of our function requestEvent. This is because onRequest() doesn’t want the function's return value (void), rather, it wants to function itself so it can run it automatically when required.
void setup() { Wire.begin(0x08); /* ... */ Wire.onRequest(requestEvent); }
4300/1000 : What’s next?
???? Surprise! ???? we technically have an i2c device! With our interrupt handled a master device should be able to request data from our peripheral and get back the output of the MsgEq7’s multiplexor as well as its position. ????
I should absolutely stop coding, pull out my Raspberry Pi, and test capturing a reading. However, I’m feeling lucky. Besides, browsing the Wire Libraries available APIs I might have noticed another interrupt we could implement: onReceive(). That sounds more fun than debugging so let’s pretend everything will be ok and implement more stuff.
Other interrupts
onReceive() is a way we can receive data from the master controller. A master device might want to send us data to configure the behaviour of a slave device, or perhaps issue a command. It works just the same as onRequest() so let’s attach a new event function we can implement later.
void receiveEvent(int numBytesReceived) { // TODO } void setup() { Wire.begin(0x08); Wire.onReceive(receiveEvent); Wire.onRequest(requestEvent); }
4467/1000 : Receiving instructions via i2c
Let’s list out some things a master device might want to configure in our MsgEq7 peripheral.
- Reset the MsgEq7.
- Set the position at the multiplexor.
- Request a large batch of data (instead of one at a time)
- Get the value of the bandpass at a specific index of the multiplexor.
We can assign an instruction byte to each of these tasks. For example, perhaps the instruction byte for resetting can be 0xB0 (0b10110000). We can capture our instruction with the .read() function.
const byte RESET_WORD = 0xB0; void receiveEvent(int numBytesReceived) { if (Wire.read() == 0xB0) { eq.reset(); } }
Let’s assign instruction bytes to each of our tasks.
const byte RESET_BYTE = 0xB0; // resets MsgEq7 const byte SET_STROBE_BYTE = 0xC0; // force position of multiplexor const byte WRITE_CYCLE_BYTE = 0xF0; // output all filters 1-7. const byte WRITE_DELTA_BYTE = 0xD0; // punch an batch of filters const byte WRITE_IMMEDIATE_BYTE = 0xA0; // punch strobe at index
SET_STROBE_BYTE has an issue. The master controller can tell us that it wan’t to push the strobe to a particular position of a multiplexor, but we haven’t given it a way to tell us which position.
We could solve this by extending our instruction byte into an instruction word where the first byte is an argument and the second is a command.
Instead of two bytes, I’m going to split my existing instruction byte in half, where the first four bits is the instruction, and the second four bits is an optional argument.
For example: if a master device wants to strobe to position 3 it would send 0b1100 0011 or 0xC3.
- 0b1100 (0xC) tells us that the master wants to set the strobe position
- 0b0011 (0x3) tells us which position
void receiveEvent(byte numBytes) { byte instruction; byte argument; while (0 < TinyWireS.available()) { /* protocol 0b cccc aaaa where : c = action to take a = optional argument */ byte received = TinyWireS.read(); instruction = received & 0xF0; argument = received & 0x0F; } /* ... */ }
Instead of if statements let’s use a switch to parse and complete the instruction. Notice the punchNextBand() and punchBandAt() function handle actually writing to the bus.
void punchNextBand() { writeToi2c(eq.index, eq.readNextBand); } void punchBandAt(int a) { int reading = eq.readBandAt(a); writeToi2c(eq.lastIndex, reading); } void receiveEvent(byte numBytes) { /* ... */ switch (instruction) { case RESET_BYTE: eq.reset(); break; case SET_STROBE_BYTE: eq.strobeTo(argument); break; case WRITE_CYCLE_BYTE: for(int b = 0; b < eq.num_bands; b++) { punchBandAt(b); } break; case WRITE_IMMEDIATE_BYTE: punchBandAt(argument); break; case WRITE_DELTA_BYTE: for(int b = 0; b < argument; b++) { punchNextBand(); } break; default: break; }
Make your own instructions
You might choose to implement your own instruction. Perphase 0xE0 could return the sum of all the filters.
Perhaps you could write a function that takes rapid samples of a filter and returns the average.
4915/1000 : Unsuccessful test.
We can read instructions from the bus!
We can write MsgEq7 data to the bus!
We should have a working i2c peripheral.
…
It doesn’t work.
Oops!! ⚠️ ‼️
In the video above we saw that we were getting lots of bad data so we’ve gone wrong somewhere.
The clue was that our master device was always receiving two bytes more than it was expecting.
When the Pi issues a command it needs to request the response. In the example, our Pi asked for all 7 filters and the peripheral immediately returned 14 bytes. When those 14 bytes are read off, the onRequest() interrupt is triggered and throws two additional unwanted bytes on the bus. Those two bytes sit on the bus as dead data. They appear as glitches when the Pi attempts future requests.
An easy fix.
My assumption going into this was that, after the onRequest was called, any dead data on the bus would be cleared automatically. That assumption was wrong.
One easy fix is to just remove the onRequest interrupt entirely. The consequence would be that the only way our Raspberry Pi can get data is by first writing an instruction to the bus.
- Ask for specific data
- Get that specific data
I don’t like this solution because it makes interfacing with our peripheral a pain. Further, reading the next band over and over in a loop is a desirable behaviour. It’s pretty much the default behaviour for the MsgEq7.
A better solution.
We want to keep requestEvent() because it preserves a simple default behaviour as an alternative to our instruction bytes. However, we only want this default behaviour if there is no other previously requested data already on the bus. I.e. if we just received an instruction, like a write_cycle_byte (0xF0), we want to skip over eq.readNextBand().
To achieve this there are two approaches:
- We can have our peripheral keep track of what data it has written to the bus
- When the master sends us an instruction, we DO NOT immediately write the answer to the i2c bus. Instead, we store that result in a buffer to be sent later when the onRequest interrupt is fired.
I preferred option b).
When data is requested from our peripheral we first check if we have any cached data that was triggered by an instruction.
- If we have data in the cache, we can send that.
- If we don’t have any data in the cache, we can default to eq.readNextBand().
Option b) has the bonus that all data is sent only when it’s requested and not before. This feels harmonious with the i2c protocol.
5368/1000 : Output buffering.
I’ve got a clever solution for this inspired by keyboard input buffering. First thing we are going to do is package our i2c outgoing message into a data structure. Now it’s easy to store and recall them.
struct i2cMessage { byte lsb; byte msb; }; i2cMessage makei2cMsg(byte msb, byte lsb) { i2cMessage msg; msg.msb = msb; msg.lsb = lsb; return msg; }
Next, let’s make a new type called i2cBuffer. This object will hold an array containing 128 of our i2cMessages. It will store our messages while we wait for them to be requested by the controlling device.
class i2cBuffer { protected: static const int bufSize = 128; i2cMessage postables[bufSize]; int writeMark; // position in postables to write int readMark; // position is postables to read public: i2cBuffer() { writeMark = 0; readMark = 0; } /* ... */ }
Write and Read Markers
Notice, also, we have two markers: one for writing and one for reading. The idea is that, when something needs to be put in our buffer, we place it at the index of the writeMark. Then we increment the writeMark ready for the next message. When we want to read something from the buffer, we read it from the index of the readMark, which of course increments.
Having separate pointers for reading and writing allows the peripheral to handle arbitrary amounts of requests and receives in arbitrary orders.
class i2cBuffer { /* ... */ void add(i2cMessage msg) { postables[writeMark] = msg; writeMark = incrementMarker(writeMark); } i2cMessage getNext() { i2cMessage msg = postables[readMark]; readMark = incrementMarker(readMark); return msg; } /* ... */ }
Circular Buffer
Here is an animation I made to get our heads around circular buffers. The top red tag is the write marker and the bottom blue tag is the read marker. The triangle on the left follows what’s being written and the triangle on the right follows what’s being read.
Some things to observe:
- The read maker can’t overshoot the write marker. Otherwise, it will be reading garbage data.
- The write marker can’t lap the read maker, since then we will be overwriting data before it has a chance to be read.
- When the circular buffer overflows, it automatically loops round.
int incrementMarker(int mark) { mark = mark + 1; mark = mark % bufSize; return mark; }
-
We can measure the number of i2cMessages cached by checking the difference between the write mark and the read mark.
-
If we want to read off all the unsent messages, we simply continue to read until the read maker catches up to the write marker.
bool isEmpty() { return (readMark == writeMark); };
5777/1000 : Using our circular buffer to resolve the bug.
Let’s start by making an instance of our new i2cBuffer object.
i2cBuffer i2cOuts = i2cBuffer();
Cache data on the buffer
Then, let’s make a new function called appendToI2COutput which takes as arguments our MsgEq7 Multiplexor index and its peak.
void appendToI2COutput(int idx, int val) { /* protocol : 0b.iii ..vv vvvv vvvv where : i = multiplexor pos (0b000-0b110) v = peak amplitude of band (0x000-0x3FF) */ byte lsb = val & 0xff; byte msb = (val >> 8) | (idx << 4); i2cMessage msg = makei2cMsg(msb, lsb); i2cOuts.add(msg); }
We had two functions called punchNextBand() and punchBandAt() and they used to just write the data to the i2c bus. Now we want to cache the data first, so let’s modify them to use the appendToI2COutput() function we just made.
void punchNextBand() { int idx = eq.index; int dat = eq.readNextBand(); appendToI2COutput(idx, dat); } void punchBandAt(int i) { int dat = eq.readBandAt(i); int idx = eq.lastIndex; appendToI2COutput(idx, dat); }
Get data off the buffer and write to the i2c.
We need to revisit requestEvent(). There are two cases to handle:
DEFAULT CASE: Just to send the next band. We ONLY want the default behavior if the master has not requested special data with an instruction. The way we know if the master has requested data is to check if our circular buffer has any data in it.
SENDING REQUESTED DATA: If the master has requested data we need to retrieve it from the buffer where we stored it. Recall that a simple way to read off all the data from our circular buffer is just to keep reading until our readMark catches up to our writeMark (i.e. they are equal).
Here is our new requestEvent function. Now, if the master device requests data, it will receive either any data it requested, or, if it has never requested data, it will get the next bandpass filter from the MsgEq7.
void requestEvent() { if (i2cOuts.isEmpty()) { punchNextBand(); } while(!i2cOuts.isEmpty()) { writeToi2c(i2cOuts.getNext()); } }
6108/1000 : Using Raspberry Pi to interface with our new i2c peripheral
Now that we have a more robust system for handling requested and received data our MsgEq7 i2c peripheral should be ready to rock. This time it works. ????
6155/1000 : Raspberry Pi code example
On my Raspberry Pi, I’m using the excellent crate RPPAL which contains the i2c interface. I want to explore a commented example because it will teach how we can read data off the i2c peripheral. Notice, to read our serialized data, we execute all the bitwise operations in reverse.
fn print_band(lsb : u16, msb : u16) { /* extract the index by shift it right then &'ing with 0b111 */ let strobe : u16 = msb >> 4 & 0x07; /* pull the top two bits of our value by &'ing with 0b11 combine with the bottom eight bits by |'ing them */ let val : u16 = (msb & 0x03) << 8 | lsb; /* we can visualize our filter with a horizontal bar chart Let's scale our value to a number between 1 and 12 */ let c : u8 = (val as f32 / 1024.0 * 12.0) as u8; /* construct a string filled with stars for our barchart no stars means filter is 'off' and 12 stars means filter is excited */ let mut bar : String = String::new(); for _i in 0..c { bar.push('*'); } /* print the multiplexor index and it's filter's barchart */ println!("{}:{}", strobe, bar); } fn main() -> Result<(), Box> { let mut i2c : I2c = I2c::new()?; // initialise our i2c i2c.set_slave_address(0x08)?; // find our i2c MsgEq7 peripheral. i2c.set_timeout(20)?; { let mut buffer : [u8;2] = [0u8; 2]; // expecting two bytes let cmd : [u8;1] = [0xA2]; // command: 'read immediate'-filter#2 i2c.write_read(&cmd, &mut buffer)?; // write instruction and get response /* Outputs : 2:****** from 01100010 01001100 */ print_band(buffer[0] as u16, buffer[1] as u16); } Ok(()) // if we made it here we got no errors }
Testing default behavior
Let’s design a test that checks that our peripheral is working.
- Use the reset instruction to trigger the reset pin of our MsgEq7 and bring us to a known state
- Use the delta instruction to ask for the next 11 filters in a batch. (0,1,2,3,4,5,6,0,1,2,3)
- Read those 11 filters off the band
- Use the set strobe instruction to bring our multiplexor to index 6
- Start requesting data in a loop. This should just return the next band on each pass since that’s our default behavior (6, 0, 1, 2, 3, 4, 5, …)
Here is our code to do that.
{ println!("- Reset MsgEq7"); let cmd : [u8;1] = [0xB0]; // instruction to reset i2c.write(&cmd)?; } { println!("- Calling 8 units"); let mut buffer : [u8;16] = [0u8; 16]; // expect 16 bytes returned let cmd : [u8;1] = [0xD8]; // request 8 filters i2c.write_read(&cmd, &mut buffer)?; // send instruction and request data back print_incoming(buffer.to_vec()); // print our response as raw bytes } { println!("- Jump to band #5"); let cmd : [u8;1] = [0xC5]; // instruction to strobe to index 5 i2c.write(&cmd)?; } println!("- Test Default Behaviour"); let mut dat : [u8;2] = [0u8; 2]; // on each loop, expect 2 bytes loop { i2c.read(&mut dat)?; // request some data (expect default) print_band(dat[0] as u16, dat[1] as u16); // print band as bar chart }
OUTPUT - Reset MsgEq7 - Calling 8 units 00000000 : 10101100 00010010 : 11010000 00100010 : 10011100 00110001 : 11001101 01000001 : 10010110 01010010 : 00101110 01100010 : 00101111 00000000 : 10010010 - Jump to band #5 - Test Default Behaviour 5:***** 6:***** 0:* 1:********* 2:** 3:*** 4:*** 5:**** 6:**** 0:* 1:******** 2:**** 3:*** 4:** 5:*** 6:*** … (on and on forever)
6699/1000 : It’s working! Let’s make particle physics ✨
At last, we have a MsgEq7 i2c peripheral with a robust protocol. That means we can use an MsgEq7 with any device, including a Raspberry Pi.
We chose to convert our MsgEq7 into an i2c device because we wanted to take advantage of the processing power, and desktop environments, of single-board computers. So, to celebrate our efforts, I’d like to show off our spectral i2c device and make some particle physics with the Pi400.
How does the Frequency Viz Animation work?
I didn’t want to do a deep dive into how the Particle Physics Spectral Visualizer works. It’s supposed to be a fun example of what you can do with MsgEq7. But if you liked it and want to learn some creative coding leave a comment under this project in the Core Electronics Forums. It’s not hard code to understand and I’m happy to walk through it if people are interested.
6875/1000 : Where to get the code ⌨️
You can get everything you need to build this project on the GitHub repo here.
That includes
- The code for the i2c peripheral
- The code for the particle physics example animation
- The code to test the i2c device at the terminal
- All the schematics with a BOM
- Test audio and Images
Thanks for reading
I hope you enjoyed this brief and concise explanation of the MsgEq7 and i2c peripherals. I hope you will forgive me that I brushed a little over our agreed word count but I assure you all additional glyphs were used with sincere heartache and as a last resort.
Happy making.
Pix ❣️