The NoiseCard's firmware is entirely available from the project's repository. It's fairly compact with the main source file containing less than 200 lines, but those lines are fairly busy in what they do. Rather than walk through it line-by-line, I'll just talk about each component of the firmware and what it contributes:
ChibiOS
void i2sCallback(I2SDriver *i2s);
std::array<uint32_t, I2S_BUFFER_SIZE> i2sBuffer;
I2SConfig i2sConfig = {
/* TX buffer */ nullptr,
/* RX buffer */ i2sBuffer.data(),
/* Size */ i2sBuffer.size(),
/* Callback */ i2sCallback,
/* Settings for the STM32's I2SCFGR and I2SPR registers... */
0, 0
};
// Then, just two lines to start the microphone:
i2sStart(&I2SD1, &i2sConfig);
i2sStartExchange(&I2SD1);
// The callback will now continuously fire as data comes in. This occurs as each
// half of the buffer is filled, so data can be processed in one half while the
// other half receives new data.
The RTOS comes in a lightweight version called "NIL" that I used initially given the little space available on my microcontroller (32kB flash, 8kB RAM). Only two threads were used: a worker thread that managed decibel calculation and LED indication, and an idle thread that could enter the MCU's sleep mode while it waited for more samples to come in. The interrupt handler for the microphone would filter and equalize the incoming data, using a semaphore to wake up the worker thread once enough data was processed.
This system worked well, but as I optimized for lower power usage I ended up scrapping the threads and RTOS component. The code is simple enough to be a little "superloop": sleep until new data is ready, process the new data, display the result, repeat:
for (;;) {
// The "Wait For Interrupt" (WFI) instruction puts the processor to sleep until
// an interrupt fires. With SLEEPONEXIT, the processor will return to sleep
// after the interrupt completes.
// So, the code below __WFI() will not excecute until the SLEEPONEXIT bit is
// cleared, which is done by the microphone's interrupt once enough samples
// have been processed.
SCB->SCR |= SCB_SCR_SLEEPONEXIT_Msk;
__WFI();
// Since microphone data collection is ongoing, use std::exchange() to retrieve
// the calculated sum_sqr and count values and simultaneously reset them to 0.
// sos_t is a type for the software floating-point implementation.
const auto sum_sqr = std::exchange(Leq_sum_sqr, sos_t(0.f));
const auto count = std::exchange(Leq_samples, 0);
// Calculate the final decibel (dBA) measurement.
const sos_t Leq_RMS = qfp_fsqrt(sum_sqr / qfp_uint2float(count));
const sos_t Leq_dB = MIC_OFFSET_DB + MIC_REF_DB + sos_t(20.f) *
qfp_flog10(Leq_RMS / MIC_REF_AMPL);
// Finally, round the measurement to an integer and display it.
const auto n = std::clamp(qfp_float2int(Leq_dB), 0, 999);
blinkDb(n);
}
This ended up reducing power draw by 12% or so, most likely due to the removal of the RTOS's periodic tick and other overhead.
ESP32-I2S-SLM
ESP32-I2S-SLM provides code to accurately measure decibels with the SPH0645 microphone. For this project, I re-implemented parts of it in modern C++ and adapted it to run on the STM32 microcontroller. The code can be found in sos-iir-filter.h.
This library works by implementing second-order sections (SOS) infinite impulse response (IIR) filters. Apart from the filtering algorithm, structures are defined with coefficients for the microphone's frequency response (to flatten the response for more accurate readings) and to apply either A- or C-weighting for calculating dBA or dBC values.
These calculations result in two values: a sum of squares of all of the processed samples (sum_sqr) and a count of how many samples are included in that sum. The microphone interrupt increments these values over time, then the main loop can simply use these two values to produce the final reading.
Qfplib
The biggest drawback from using the chosen microcontroller is its lack of a hardware floating-point unit. Since all of these calculations depend on decimal-point accuracy, floating-point (or fixed-point) processing is a necessity. The GCC compiler provides a software floating-point implementation, but it's dreadfully slow. This led me to Qfplib, which implements floating-point operations that are optimized to specific architectures like the STM32G0's ARM Cortex-M0+ core.
The functions are written in assembly, with a C header for interfacing. For ease of use, I wrapped these functions in a C++ class called sos_t that had operator overloads for addition, division, etc.
With this library, processing time was significantly reduced. I put that towards more time to sleep the processor, but it also could have gone towards processing more microphone samples: since this operation is still so slow, the firmware only takes part of the microphone samples it collects to calculate decibel readings. At the moment, this is actually just 16 samples per 512 sample block -- 512 samples at our 48kHz sampling rate gives us enough time to process 16 samples and return to sleep mode for a useful amount of time.
A faster option would be a fixed-point math library, which uses integer representations of decimal numbers. I just haven't found a workable solution yet; I believe part of the issue is the wide spread of values that this code requires, from 18-bit microphone readings to many-decimal values in the filters' coefficients.
STM32 provides fast, low-power microcontrollers that include hardware floating-point units, but those are at least twice as expensive as the STM32G0.
Low-power configuration
The final piece to this firmware is how it achieves its low power operation, which I last measured at 1.75mA @ 1.8V. This is what it came down to:
Lowering clock speeds
The microphone needs at least 3MHz to run at the desired 48kHz sampling rate. I went with a processor clock of 16MHz to balance low clocks and fast execution of the code. I did experiment multiple times with running fast and sleeping more often (the MCU runs at up to 64MHz), but I could never get lower than what I achieved with 16MHz.
ChibiOS's mcuconf.h header made configuring clock speeds easy and quick.
Reducing processor power draw
First, I chose to run the entire NoiseCard at 1.8V since the microcontroller and microphone both supported it. Next, the MCU can internally run at two different "ranges":
The 16MHz processor speed I chose gives the best performance possible in Range 2. Remaining in Range 2 vs. Range 1 typically saves around 20% of processor power (see the datasheet).
An additional chunk of power can be saved by running code from SRAM rather than Flash. Since the microphone interrupt handler was running most often, I marked its functions to be placed in SRAM. This also included taking pieces of the Qfplib math library and placing them in C functions so they could also be placed in RAM. This saved at least 0.1mA of average consumption.
Sleep modes
Of course, entering sleep modes whenever possible made a huge difference in power draw. This is done in the firmware through the Wait For Interrupt instruction which corresponds to the "regular" sleep mode.
The STM32G0 can do even better with its "low-power run" and "low-power sleep" modes, but they require a system clock speed of 2MHz. Again, the microphone needs at least 3MHz to continuously collect data. If the system were redesigned, perhaps the mic could be run at 2MHz for a 32kHz sampling rate, though the filtering would need recalculations and new testing for accuracy. The processor couldn't calculate much at 2MHz either, so some dynamic frequency scaling (or hardware floating-point support) would also be needed.
When I first started working on the NoiseCard, its breadboard prototype was running at something near 7mA @ 3.3V or 23 milliwatts. With all of the above, the current NoiseCard is down to 1.75mA @ 1.8V or 3.15mW -- an 86% reduction! I'll bet that this could be pushed even lower, but for now it's more than enough to produce an accurate, low-power NoiseCard.
Discussions
Become a Hackaday.io Member
Create an account to leave a comment. Already have an account? Log In.