Sunday, March 22, 2026

0000 0001 0000 1101

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:

  1. The Initialisation: A rapid timing check to ensure all segments are healthy, includes blinking, fading individually and fading all LEDs.

  2. The Starfield: A randomized "Twinkle" engine that varies peak brightness and pulse duration, ensuring no two flashes look the same.

  3. 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

  • Efficiency: We achieved complex filtering and 8-channel PWM while staying under 90% Flash usage.

    Sketch uses 14496 bytes (88%) of program storage space. Maximum is 16384 bytes. Global variables use 568 bytes (27%) of dynamic memory, leaving 1480 bytes for local variables. Maximum is 2048 bytes

    Note that I used the Arduino IDE (Version: 2.3.8, 
    Date: 2026-02) with the core coming from this github. If you use the JSON and Arduino IDE preferences to install the board definitions, remember to download the update from the github site and, after unzipping, overwrite the files found here:


  • 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!





No comments:

Post a Comment