From Blinken-lights to Organic VU Meters: Optimising the CH32V003
The WCH CH32V003 is a marvel of modern "frugal" engineering: a RISC-V core for roughly the price of a postage stamp. This project represents the arc of moving from basic GPIO toggling to a sophisticated, multi-mode lighting controller, testing various aspects of "normal" Arduino programming (blinking, fading, analog reads, randomising, etc.) as we progress through the code. The Development Arc: Beating the "Flash" Wall
Our journey began with a simple goal: create a high-quality 8-LED array with organic transitions. The first hurdle was PWM. Without enough hardware PWM pins for the entire array, we implemented a custom Software PWM engine. This allowed us to achieve silky 8-bit fading (0-255) on any GPIO, giving the "Breathe" and "Twinkle" modes a natural, non-linear glow.
The real challenge arrived with the Voice-Reactive VU Meter. Initially, using standard floating-point math for signal smoothing instantly "overflowed" the 16KB Flash limit. By pivoting to Fixed-Point Integer Math and bit-shifting (using >> 7 instead of division), we reclaimed over 4KB of space while actually increasing execution speed. Thanks Gemini!
Hardware & Wiring
The setup is centered around a CH32V003 breakout and a custom 8-LED module. We used a standard analog microphone module to provide the audio input.
Key Connections:
LED Array: PD4, PC4, PC3, PC2, PC1, PC0, PA2, PA1 (mapped in code via LedArray[]).
Mic Input: Analog Pin A4 (PA4).
Power: 3.3V to 5V (depending on your specific board and LED resistors).
The Result: Organic Interaction
The firmware cycles through three distinct phases:
The Initialisation: A rapid timing check to ensure all segments are healthy, includes blinking, fading individually and fading all LEDs.
The Starfield: A randomized "Twinkle" engine that varies peak brightness and pulse duration, ensuring no two flashes look the same.
The Dampened VU Meter: This is the star of the show. Using an Exponential Moving Average (EMA) filter, the meter ignores high-frequency noise and tracks the "envelope" of your voice. The result is a meter that feels "weighted"—more like an analog needle than a jittery digital display.
Strengths & Lessons Learned
Calibration: By externalising MIC_FLOOR and MIC_CEILING constants, the module is easily tuned for different environments.
The Power of RISC-V: Even at this price point, the 48MHz clock handles our software PWM and bit-shifted math without a hint of flicker.
Future Directions
While the current module is solid, there is room to grow. Future iterations could include a "Peak Decay" feature (where the highest LED stays lit momentarily) or a FFT-based frequency visualiser.
For now, it’s a nice refutation of my last mailbag video which not only featured an embarrassment of riches, but also just plain embarrassment!
Check out the full source code on my GitHub: bovineck/CH32V003-module-code
/*
CH32V003 module programmed by OneCircuit and Gemini
Sat 21 Mar 2026 18:12:43 AEDT
YouTube: https://www.youtube.com/@onecircuit-as
Blog: https://onecircuit.blogspot.com/
Github: https://github.com/bovineck/
*/
const uint8_t LedArray[] = { PD4, PC4, PC3, PC2, PC1, PC0, PA2, PA1 };
const uint8_t sizeArray = 8;
const int timeDelay = 300;
const uint8_t breatheSpeed = 2;
const int filterFactor = 6; // to dampen the sound readings
int dampenedVolume = 0;
// VU Meter Tuning
const int MIC_FLOOR = 50; // Ignore noise below this level
const int MIC_CEILING = 450; // Full scale (all LEDs on) at this level
const int micRange = MIC_CEILING-MIC_FLOOR;
const uint8_t LED_COUNT = 8; // Total number of LEDs
void initialise_pins(int timing) {
for (int mypins = 0; mypins < sizeArray; mypins++) {
pinMode(LedArray[mypins], OUTPUT);
digitalWrite(LedArray[mypins], HIGH);
delay(timing);
digitalWrite(LedArray[mypins], LOW);
}
}
void twinkle(int durationMillis) {
unsigned long start = millis();
while (millis() - start < durationMillis) {
int ledA = random(0, sizeArray);
int peak = random(40, 180); // Random max brightness (out of 255)
int speed = random(1, 5); // Random increment (1 = slow, 5 = fast)
int timing = random(5, 12); // Random pulse width multiplier
for (int duty = 0; duty < peak; duty += speed) {
digitalWrite(LedArray[ledA], HIGH);
delayMicroseconds(duty * timing);
digitalWrite(LedArray[ledA], LOW);
delayMicroseconds((peak - duty) * timing);
}
for (int duty = peak; duty > 0; duty -= speed) {
digitalWrite(LedArray[ledA], HIGH);
delayMicroseconds(duty * timing);
digitalWrite(LedArray[ledA], LOW);
delayMicroseconds((peak - duty) * timing);
}
delay(random(50, timeDelay));
}
}
void setup() {
initialise_pins(0);
randomSeed(analogRead(0));
Serial.begin(115200);
delay(2000);
}
void loop() {
Serial.println(F("Blinken de lights"));
initialise_pins(timeDelay);
delay(timeDelay);
Serial.println(F("Faden de lights"));
// Fade Up
for (int mypins = 0; mypins < sizeArray; mypins++) {
for (int duty = 0; duty < 255; duty++) {
digitalWrite(LedArray[mypins], HIGH);
delayMicroseconds(duty * 10);
digitalWrite(LedArray[mypins], LOW);
delayMicroseconds((255 - duty) * 10);
}
// Fade Down
for (int duty = 255; duty > 0; duty--) {
digitalWrite(LedArray[mypins], HIGH);
delayMicroseconds(duty * 5);
digitalWrite(LedArray[mypins], LOW);
delayMicroseconds((255 - duty) * 2);
}
}
delay(timeDelay);
Serial.println(F("All de lights Faden"));
for (int direction = 0; direction < 2; direction++) {
for (int dutyCycle = 0; dutyCycle < 255; dutyCycle++) {
int duty = (direction == 0) ? dutyCycle : (255 - dutyCycle);
for (int times = 0; times < breatheSpeed; times++) {
for (int i = 0; i < sizeArray; i++) digitalWrite(LedArray[i], HIGH);
delayMicroseconds(duty * 10);
for (int i = 0; i < sizeArray; i++) digitalWrite(LedArray[i], LOW);
delayMicroseconds((255 - duty) * 10);
}
}
}
delay(timeDelay);
Serial.println(F("Twinklen de lights"));
twinkle(20 * timeDelay);
delay(timeDelay);
while (1) {
int rawVolume = analogRead(A4);
dampenedVolume = ((dampenedVolume * (128 - filterFactor)) + (rawVolume * filterFactor)) >> 7;
int response = (dampenedVolume - MIC_FLOOR) * (LED_COUNT + 1) / micRange;
if (response < 0) response = 0;
if (response > sizeArray) response = sizeArray;
for (uint8_t i = 0; i < sizeArray; i++) {
digitalWrite(LedArray[i], (i < response));
}
delay(10);
}
}
...and of course as usual check out the video below!