Gotta Go Faster, Part 9.


This last entry in this series of posts about using the Teensy instead of the Arduino contains the full source code for my amplifier. Remember is is quite specific for my needs and taste but I post it as a reference. I use a Teensy 4.0 together with an ADAU1701 based DSP connected over I2S. The output is rendered on a 8 x 32 RGB LED matrix commonly found on Amazon and the like.

The difference between using the Arduino with an analog input, FHT and integer math etc. compared to the Teensy, I2S, FFT, floating point math, WS2812Serial and FastLED is like night and day. The response now is amazingly quick with a great resolution and butter smooth output. I am extremely happy with the end result. Below is the full source code listing including the support classes library.

// Teensy
#include <Audio.h>
#include <Wire.h>
#include <SPI.h>
#include <SD.h>
#include <SerialFlash.h>
// PIN
const uint8_t   LED_PIN       = 1;
const uint8_t   INPUT_PINS[]  = {2, 3, 4, 5};
const uint8_t   MODE_PIN      = 6;
// pin 8 used for I2S IN1 on Teensy 4.0
// pin 13 is onboard led pin on Teensy 4.0
const uint8_t   STATUS_PIN    = 13;
const uint8_t   OUTPUT_PINS[] = {16, 17, 18, 19};
// pin 20 uded for I2S LRCLK on Teenst 4.0
// pin 21 uded for I2S BCLK on Teenst 4.0
// MEM
const uint8_t   BUFFER_LEN    = 20;
const uint8_t   BUFFER_ADR    = 0x10;
// BTN
const uint8_t   NUM_INPUT     = sizeof(INPUT_PINS) / sizeof(INPUT_PINS[0]);
const uint8_t   DEF_INPUT     = 0;
// LDR
const uint8_t   LDR_ADC       = 0; // pin 14
const uint16_t  LDR_MIN       = 100;
const uint16_t  LDR_MAX       = 600;
const uint8_t   MAX_DIM       = 192;
const float     LDR_ALPHA     = 0.01;
// LED
const uint8_t   XRES          = 32;
const uint8_t   YRES          = 8;
const uint16_t  NUM_LEDS      = XRES * YRES;
const uint16_t  MAX_POWER     = 4 * 1000; // milliwatts
const uint8_t   BRIGHTNESS    = 128;
#include <WS2812Serial.h>
#define USE_WS2812SERIAL
#define FASTLED_INTERNAL
#include <FastLED.h>
// FFT
const uint8_t   NUM_BAND      = XRES / 2;
const float     BND_ALPHA     = 0.2;
// SND
const float     SILENCE       = 0.02;
const uint8_t   PEAK_HOLD     = 32;
const float     PEAK_DECAY    = 0.02;
const float     CHN_ALPHA     = 0.9;
// ETC
const uint16_t  POWERUP_MS    = 2000;
const uint16_t  GRADIENT_MS   = 1000;
const uint32_t  SILENCE_MS    = 60000;
const uint8_t   UPDATE_FPS    = 60;
const uint8_t   UPDATE_MS     = 1000 / UPDATE_FPS;
const uint8_t   FIRE_FPS      = 15;
const uint8_t   FIRE_MS       = 1000 / FIRE_FPS;
#include "SupportClasses.h"

struct SoundLevel {
  float value   = 0;
  float peak    = 0;
  uint8_t hold  = 0;
  // update with new reading
  void update(float reading) {
    value = reading;
    if (value > peak) {
      peak = value;
      hold = PEAK_HOLD;
    } else if (hold > 0) {
      hold--;
    } else if (peak > 0) {
      peak -= PEAK_DECAY;
    }
  }
};

bool                    altMode       = false;
bool                    noLight       = true;
bool                    noSound       = true;
uint8_t                 dimValue      = 0;
uint8_t                 gradientHue   = 0;

SoundLevel              leftCh;
SoundLevel              rightCh;
SoundLevel              band[NUM_BAND];
Persistent<uint8_t>     selectedInput;
Timer                   fireTimer;
Timer                   silenceTimer;
Trigger                 updateTrigger;
Trigger                 gradientTrigger;
Debounce                modeButton;
Debounce                inputButton[NUM_INPUT];
FireMatrix<XRES, YRES>  fire;
CRGB                    leds[NUM_LEDS];
// Teensy Audio
AudioInputI2Sslave      i2sSlave;
AudioMixer4             mixer;
AudioAnalyzePeak        peakLeft;
AudioAnalyzePeak        peakRight;
AudioAnalyzeFFT1024     fft1024;
AudioConnection         patchCordPeakLeft(i2sSlave, 0, peakLeft, 0);
AudioConnection         patchCordMixerLeft(i2sSlave, 0, mixer, 0);
AudioConnection         patchCordPeakRight(i2sSlave, 1, peakRight, 0);
AudioConnection         patchCordMixerRight(i2sSlave, 1, mixer, 1);
AudioConnection         patchCordFft1024(mixer, fft1024);

// map x, y to pixel index
uint16_t pixel(uint8_t x, uint8_t y) {
  const uint16_t index = XRES * (YRES - y);
  return (y & 0x01) ? index - XRES + x : index - 1 - x;
}

// update with light adjusted rgb pixel
void lightAdjPixel(uint8_t x, uint8_t y, CRGB color) {
  const uint16_t index = pixel(x, y);
  leds[index] = color;
  leds[index].fadeLightBy(dimValue);
  if (noLight) leds[index].fadeLightBy(128);
}

// clamp reading between 0.0 and 1.0
float clamp(float reading) {
  const float temp = reading < 0 ? 0 : reading;
  return temp > 1 ? 1 : temp;
}

// scale reading to display resolution
uint8_t scale(float reading, uint8_t resolution) {
  reading = clamp(reading);
  return reading > SILENCE ? round(reading * resolution) : 0;
}

// convert I2S reading [0,1] to dBFS
float dbfs(float reading) {
  return 20 * log10(abs(reading));
}

// simple exponential smoothing, alpha is the smoothing factor [0,1]
// alpha closer to 1, less smoothing, greater weight to recent changes
// alpha closer to 0, more smoothing, less responsive to recent changes
float ses(float input, float average, float alpha) {
  return average + alpha * (input - average);
}

float readBand(byte index) {
  const int BAND_LUT[NUM_BAND][2] = {
    {  1,   1}, {  2,   3}, {  4,   5}, {  6,   8},
    {  9,  12}, { 13,  18}, { 19,  25}, { 26,  35},
    { 36,  48}, { 49,  65}, { 66,  88}, { 89, 119},
    {120, 160}, {161, 214}, {215, 287}, {288, 383},
  };
  return fft1024.read(BAND_LUT[index][0], BAND_LUT[index][1]);
}

void processBands() {
  if (fft1024.available()) {
    float reading[NUM_BAND];
    float maxReading = 0;
    for (byte i = 0; i < NUM_BAND; i++) {
      reading[i] = readBand(i);
      maxReading = max(maxReading, reading[i]);
    }
    // get scale to max reading factor
    float f = maxReading > SILENCE ? 1.0 / maxReading : 0;
    for (byte i = 0; i < NUM_BAND; i++) {
      // filter and scale reading to max reading
      band[i].update(ses(reading[i] * f, band[i].value, BND_ALPHA));
    }
  }
}

void processChannels() {
  if (peakLeft.available() && peakRight.available()) {
    leftCh.update(ses(peakLeft.read(), leftCh.value, CHN_ALPHA));
    rightCh.update(ses(peakRight.read(), rightCh.value, CHN_ALPHA));
    if (leftCh.value > SILENCE && rightCh.value > SILENCE) {
      silenceTimer.reset();
    }
  }
}

void processLdr() {
  static float avg = 0;
  int value = analogRead(LDR_ADC);
  avg = ses(value, avg, LDR_ALPHA);                       // filter ldr reading
  value = round(avg);
  value = constrain(value, LDR_MIN, LDR_MAX);             // clamp to ldr min-max
  value = map(value, LDR_MIN, LDR_MAX, 0, MAX_DIM);       // map value to min-max
  dimValue = MAX_DIM - value;                             // update based on ldr value
  noLight = (dimValue == MAX_DIM);                        // is it pitch black?
}

void displayFire() {
  if (fireTimer.expired()) {
    fireTimer.reset();
    fire.update();
    for (byte x = 0; x < XRES; x++) {
      for (byte y = 0; y < YRES; y++) {
        lightAdjPixel(x, y, fire.color(x, y));
      }
    }
    FastLED.show();
  }
}

void displayChannel(SoundLevel channel, byte y) {
  static const uint8_t HI = (XRES / 4) * 3;
  static const uint8_t LO = XRES / 2;
  // get dBFS as a positive number
  uint16_t peak = round(abs(dbfs(channel.peak)));
  uint16_t level = round(abs(dbfs(channel.value)));
  // constrain to max resolution and convert to headroom
  peak = XRES - constrain(peak, 0, XRES);
  level = XRES - constrain(level, 0, XRES);
  CRGB hiColor;
  CRGB miColor;
  CRGB loColor;
  for (byte x = 1; x < XRES; x += 2) {
    hiColor = x < LO ? CRGB::Green : x > HI ? CRGB::Red : CRGB::Yellow;
    miColor = hiColor;
    miColor.fadeLightBy(64);
    loColor = miColor;
    loColor.fadeLightBy(128);
    if (peak >= x) {
      lightAdjPixel(x, y, loColor);
    }
    if (level >= x) {
      lightAdjPixel(x, y + 1, hiColor);
      lightAdjPixel(x, y + 2, hiColor);
      lightAdjPixel(x, y + 3, miColor);
    } else if (peak >= x) {
      lightAdjPixel(x, y + 1, loColor);
      lightAdjPixel(x, y + 2, loColor);
      lightAdjPixel(x, y + 3, loColor);
    }
  }
}

void displayHeadroom() {
  static const uint8_t UPPER = YRES / 2;
  static const uint8_t LOWER = 0;
  FastLED.clear();
  // left channel on upper half
  displayChannel(leftCh, UPPER);
  // right channel on lower half
  displayChannel(rightCh, LOWER);
  FastLED.show();
}

void displaySpectrum() {
  static const uint8_t HUE_STEP = 32 / NUM_BAND;
  uint8_t level;
  uint8_t peak;
  uint8_t hue;
  uint8_t x;
  uint8_t y;
  CHSV hiColor;
  CHSV loColor;
  FastLED.clear();
  for (byte i = 0; i < NUM_BAND; i++) {
    x = i << 1;
    peak = scale(band[i].peak, YRES);
    level = scale(band[i].value, YRES);
    hue = gradientHue - (i * HUE_STEP);
    hiColor = CHSV(hue, 255, 255);
    loColor = CHSV(hue, 255, 127);
    for (y = 0; y < YRES; y++) {
      if (level > y) {
        lightAdjPixel(x, y, hiColor);
      }
      if (peak > y) {
        lightAdjPixel(x + 1, y, loColor);
      }
    }
  }
  FastLED.show();
}

// set selected output and save to eeprom
void setOutput(uint8_t selection) {
  selection = selection < NUM_INPUT ? selection : DEF_INPUT;
  for (byte i = 0; i < NUM_INPUT; i++) {
    if (selection == i) {
      digitalWrite(OUTPUT_PINS[i], HIGH);
    } else {
      digitalWrite(OUTPUT_PINS[i], LOW);
    }
  }
  if (selection != selectedInput.get()) {
    selectedInput.put(selection);
  }
}

// input selection callback
void inputCallback(uint8_t pin, uint8_t mode) {
  if (mode == LOW) return;
  for (byte i = 0; i < NUM_INPUT; i++) {
    if (pin == INPUT_PINS[i]) {
      setOutput(i);
      break;
    }
  }
}

// mode selection callback
void modeCallback(uint8_t pin, uint8_t mode) {
  altMode = (mode == HIGH);
}

// gradient hue callback
void gradientCallback() {
  gradientHue--;
}

void updateCallback() {
  gradientTrigger.update();
  processLdr();
  processChannels();
  if (silenceTimer.expired()) {
    displayFire();
  } else {
    if (altMode) {
      displayHeadroom();
    } else {
      processBands();
      displaySpectrum();
    }
  }
}

void setup() {
  // turn on onboard led
  digitalWrite(STATUS_PIN, HIGH);
  // init output pins and input buttons
  modeButton.setup(MODE_PIN, modeCallback);
  for (byte i = 0; i < NUM_INPUT; i++) {
    pinMode(OUTPUT_PINS[i], OUTPUT);
    inputButton[i].setup(INPUT_PINS[i], inputCallback);
  }
  // get persisted data
  selectedInput.setup(DEF_INPUT, BUFFER_ADR, BUFFER_LEN);
  // output selected input
  setOutput(selectedInput.get());
  // init sound system
  AudioMemory(15);
  mixer.gain(0, 0.5);
  mixer.gain(1, 0.5);
  delay(POWERUP_MS);
  // init leds
  //FastLED.addLeds<NEOPIXEL, LED_PIN>(leds, NUM_LEDS);
  FastLED.addLeds<WS2812SERIAL, LED_PIN, BRG>(leds, NUM_LEDS);
  FastLED.setCorrection(TypicalLEDStrip);
  FastLED.setMaxPowerInMilliWatts(MAX_POWER);
  FastLED.setBrightness(BRIGHTNESS);
  FastLED.clear();
  FastLED.show();
  // init timers and triggers
  fireTimer.set(FIRE_MS);
  silenceTimer.set(SILENCE_MS);
  updateTrigger.setup(UPDATE_MS, updateCallback);
  gradientTrigger.setup(GRADIENT_MS, gradientCallback);
  // turn off onboard led
  digitalWrite(STATUS_PIN, LOW);
}

void loop() {
  modeButton.update();
  for (byte i = 0; i < NUM_INPUT; i++) {
    inputButton[i].update();
  }
  updateTrigger.update();
}

#ifndef SupportClasses_h
#define SupportClasses_h
#include "Arduino.h"
#include "EEPROM.h"
#include <FastLED.h>

// simple class for wear level storage in EEPROM
template <class T> 
class Persistent {
  private:
    static const uint8_t BLOCK_MARK = 0xfe;
    static const uint8_t BLOCK_FREE = 0xff;
    uint8_t blockSize;
    uint8_t blockNum;
    int blockAddr;
    int startAddr;
    int endAddr;
    T value;
    // seek last block mark and return address if found else -1
    int seek() {
      uint8_t mark;
      int result = -1;
      for (int addr = startAddr; addr < endAddr; addr += blockSize) {
        mark = EEPROM[addr];
        if (mark == BLOCK_MARK) {
          result = addr; // block mark found
        } else if (mark != BLOCK_FREE) {
          result = -1; // unformatted
        }
      }
      return result;
    }
    // mark all status bytes as free
    void format() {
      for (int addr = startAddr; addr < endAddr; addr += blockSize) {
        EEPROM[addr].update(BLOCK_FREE);
      }
    }
  public:
    // default data, start address and ring buffer slots
    void setup(T data, int address, uint8_t slots) {
      value = data;
      blockNum = slots;
      blockSize = sizeof(T) + 1;
      startAddr = address;
      endAddr = startAddr + blockNum * blockSize;
      if (endAddr > EEPROM.length()) endAddr = EEPROM.length();
      blockAddr = seek();
      if (blockAddr < 0) {
        // unformatted, format ring buffer
        format();
        blockAddr = startAddr;
        // put default data into ring buffer
        put(data);
      } else {
        // get stored data from EEPROM as value
        EEPROM.get(blockAddr + 1, value);
      }
    }
    // default data with a ten slot ring buffer from beginning of EEPROM
    void setup(T data) {
      setup(data, 0, 10);
    }
    // put data into wear level ring buffer
    void put(T data) {
      if (blockAddr < 0) return;
      int nextAddr = blockAddr + blockSize;
      if (nextAddr >= endAddr) nextAddr = startAddr;
      // put new data into ring buffer
      EEPROM.put(nextAddr + 1, data);
      // update with new mark
      EEPROM.update(nextAddr, BLOCK_MARK);
      // clear old mark after new update
      EEPROM.update(blockAddr, BLOCK_FREE);
      blockAddr = nextAddr;
      // store data as value
      value = data;
    }
    // get data from wear level ring buffer
    T get() {
      // get stored value insted of reading from EEPROM
      return value;
    }
    // return start address
    int begin() {
      return startAddr;
    }
    // return end address
    int end() {
      return endAddr;
    }
    // return length of ring buffer
    uint16_t length() {
      return blockNum * blockSize;
    }
};

class Timer {
    // a simple timer class to check if time set has expired
  private:
    unsigned long started  = 0;
    unsigned long timeout  = 0;
  public:
    void set(unsigned long ms) {
      timeout = ms;
      reset();
    }
    void reset() {
      started = millis();
    }
    bool expired() {
      return elapsed() >= timeout;
    }
    unsigned long elapsed() {
      return millis() - started;
    }
};

// trigger callback function
typedef void (*trigger_cb)();

class Trigger {
    // a simple trigger class with callback on expiry
  private:
    trigger_cb callback = NULL;
    unsigned long started   = 0;
    unsigned long interval  = 0;
  public:
    void setup(unsigned long ms, trigger_cb callback) {
      this->callback = callback;
      set(ms);
    }
    void set(unsigned long ms) {
      interval = ms;
      reset();
    }
    void reset() {
      started = millis();
    }
    void update() {
      unsigned long now = millis();
      if (now - started >= interval) {
        if (callback) callback();
        started = now;
      }
    }
};

// debounce callback function, called with pin and state (HIGH = closed, LOW = open)
typedef void (*debounce_cb)(uint8_t, uint8_t);

class Debounce {
    // debounces a normally open (NO) switch connected for internal pullup resistor
  private:
    static const uint8_t DEF_MS   = 35;
    debounce_cb callback          = NULL;
    uint8_t lastState             = LOW;
    unsigned long lastChangedTime = 0;
    uint8_t debounceTime;
    uint8_t pin;
  public:
    void setup(uint8_t pin, debounce_cb callback, uint8_t ms) {
      this->pin = pin;
      this->callback = callback;
      this->debounceTime = ms;
      pinMode(pin, INPUT_PULLUP);
      update();
    }
    void setup(uint8_t pin, debounce_cb callback) {
      setup(pin, callback, DEF_MS);
    }
    void setup(uint8_t pin) {
      setup(pin, NULL, DEF_MS);
    }
    void update() {
      unsigned long now = millis();
      if (now - lastChangedTime >= debounceTime) {
        uint8_t currentState = !digitalRead(pin);
        if (currentState == lastState) return;
        lastChangedTime = now;
        lastState = currentState;
        if (callback) callback(pin, currentState);
      }
    }
    // state is LOW if switch is open and HIGH if closed
    uint8_t state() {
      return lastState;
    }
};

class EmaFilter {
    // an exponential moving average filter using only integer math
    // supports up to 24-bit inputs and takes an alpha from 0 to 255
    // an alpha of 0 means the filtered value changes very slowly
    // an alpha of 255 means the filter returns the value of the input
  private:
    static const uint32_t MAX_VAL = 0xFFFFFF; // 24-bit max
    bool noState = true;
    uint32_t state = 0;
  public:
    uint32_t update(uint32_t value, uint8_t alpha) {
      if (noState) {
        return set(value);
      } else {
        value = value > MAX_VAL ? MAX_VAL : value;
        uint32_t newState = (value * (alpha + 1) + (state * (255 - alpha))) / 256;
        if (state == newState && value != newState) {
          if (value > newState) {
            newState++;
          } else if (value < newState) {
            newState--;
          }
        }
        state = newState;
      }
      return get();
    }
    uint32_t reset() {
      state = 0;
      noState = true;
      return get();
    }
    uint32_t set(uint32_t value) {
      state = value > MAX_VAL ? MAX_VAL : value;
      noState = false;
      return get();
    }
    uint32_t get() {
      return state;
    }
};

template <int16_t W, int16_t H> 
class FireMatrix {
  private:
    struct Flare {
      int16_t x;
      int16_t y;
      uint8_t heat;
    };
    // width of the fire matrix
    static const int16_t WIDTH  = W > 8 ? W : 8;
    // height of the fire matrix
    static const int16_t HEIGHT = H > 8 ? H : 8;
    // max number of flares
    static const uint8_t FLARES = W / 4;
    // array of flares
    Flare flare[FLARES];
    // fire matrix 4 bit heat points
    uint8_t heatPoint[WIDTH / 2][HEIGHT];
    // number of flares
    uint8_t flares = 0;

    // store a 4 bit heat point
    void putHeat(int16_t x, int16_t y, uint8_t heat) {
      uint8_t h;
      if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return;
      x >>= 1; // divide x by 2
      h = heatPoint[x][y];
      // if y is odd put heat in lower 4 bits else in upper 4 bits
      h = y & 1 ? (h & 0xf0) | (heat & 0xf) : (h & 0xf) | (heat << 4);
      heatPoint[x][y] = h;
    }

    // retrieve a 4 bit heat point
    uint8_t getHeat(int16_t x, int16_t y) {
      uint8_t h;
      if (x < 0 || x >= WIDTH || y < 0 || y >= HEIGHT) return 0;
      x >>= 1; // divide x by 2
      h = heatPoint[x][y];
      // if y is odd get heat from lower 4 bits else from upper 4 bits
      h = y & 1 ? h & 0xf : (h >> 4) & 0xf;
      return h;
    }

  protected:
    // heat-up flare and update heat points
    void heatFlare(uint8_t index) {
      Flare f = flare[index];
      int16_t b = f.heat * 10 / cooling + 1;
      for (int16_t x = (f.x - b); x < (f.x + b); x++) {
        for (int16_t y = (f.y - b); y < (f.y + b); y++) {
          if (x >= 0 && x < WIDTH && y >= 0 && y < HEIGHT) {
            int16_t d = (cooling * sqrt16((f.x - x) * (f.x - x) + (f.y - y) * (f.y - y)) + 5) / 10;
            uint8_t n = f.heat > d ? f.heat - d : 0;
            if (n > getHeat(x, y)) { // can only get brighter
              putHeat(x, y, n);
            }
          }
        }
      }
    }

    // cool down flare and delete it from flare array if out
    void coolFlare(uint8_t index) {
      Flare f = flare[index];
      if (f.heat > 0) {
        f.heat--;
        flare[index] = f;
      } else {
        // flare is out
        for (int16_t i = index + 1; i < flares; i++) {
          flare[i - 1] = flare[i];
        }
        flares--;
      }
    }

    // try to ignite new flare if there is room in flare array
    void sparkFlare() {
      if (flares < FLARES && random8(100) < sparking) {
        Flare f;
        f.x = random16(0, WIDTH);
        f.y = random16(0, (HEIGHT / 8) + 1);
        f.heat = 10;
        flare[flares] = f;
        flares++;
      }
    }

  public:
    // default palette based on FastLED HeatColors
    CRGBPalette16 palette = CRGBPalette16(
                              0x000000, 0x330000, 0x660000, 0x990000, 0xCC0000, 0xFF0000, 0xFF3300, 0xFF6600,
                              0xFF9900, 0xFFCC00, 0xFFFF00, 0xFFFF33, 0xFFFF66, 0xFFFF99, 0xFFFFCC, 0xFFFFFF
                            );
    // flame cool-off rate, default 14
    uint8_t cooling = 14;
    // chance of sparking new flame in pecent, default 40
    uint8_t sparking = 30;

    // call periodicly to update fire matrix
    void update() {
      int16_t x, y;
      // move all existing heat points up the display and cool off
      for (y = HEIGHT - 1; y > 0; y--) {
        for (x = 0; x < WIDTH; x++) {
          uint8_t h = getHeat(x, y - 1);
          putHeat(x, y, h > 0 ? h - 1 : 0);
        }
      }
      // heat-up the bottom row
      for (x = 0; x < WIDTH; x++) {
        putHeat(x, 0, random8(5, 9));
      }
      // glow and cool off flares
      for (x = 0; x < flares; x++) {
        // heat-up flare
        heatFlare(x);
        // cool-down flare
        coolFlare(x);
      }
      // try spark a new flare
      sparkFlare();
    }

    // get color from fire matrix
    CRGB color(int16_t x, int16_t y) {
      if (x >= 0 && x < WIDTH && y >= 0 && y < WIDTH) {
        return ColorFromPalette(palette, 24 * getHeat(x, y));
      } else {
        return CRGB::Black;
      }
    }
};

#endif