WIP Beat Detecting Circular Equalizer

I took advantage of @jeff’s beat detection code to make an equalizer pattern that displays the various frequency buckets as radii from the center of the board. It should render as round-ish on non ring displays as well. Current primary WIP is figure out what do do during song fade-in and out when there’s still audio but beatDetected() hasn’t fired for a bit. I’m also going to do something basic for render(). Ideas welcome!

export var frequencyData;
export var energyAverage, maxFrequency, maxFrequencyMagnitude;

export var hArr = array(pixelCount + 1);

BUCKET_WIDTH = PI2 / frequencyData.length;

//rotating display

adjustedBuckets = array(frequencyData.length);

var outerRadius = 0.4;
var whiteRadius = 0.05
var decayRate = 250;
var decay = 0;

export function sliderOuterRadius(v) {
    outerRadius = v / 2;
}

export function sliderWhiteRadius(v) {
    whiteRadius = v / 2;
}

export function sliderDecayRate(v) {
    decayRate = abs(1 - v) * 1000;
}

var msSinceUpdate = 0;
export function beforeRender(delta) {
  processBass(delta);

  if (msSinceUpdate >= decayRate) {
    decay += 0.01
    if (decay < 0) {
      decay = 0;
    }

    msSinceUpdate = 0;
  } else {
    msSinceUpdate += delta;
  }
}

export function render(index) {
  hsv(index / pixelCount, 1, 0.33);
}

export function render2D(index, x, y) {
    var angle = computeAngle(x, y);
    var radius = hypot(x - 0.5, y - 0.5) + decay;
    if (radius < whiteRadius) {
        hsv(0, 0, 0.5);
        return;
    }

    var bucketAngle = angle / BUCKET_WIDTH;
    var lowerBucket = floor(bucketAngle);
    var upperBucket = ceil(bucketAngle) % frequencyData.length;

    var energy;
    if (lowerBucket === upperBucket) {
        energy = adjustedBuckets[lowerBucket] / 2;
    } else {
        if (upperBucket >= frequencyData.length || lowerBucket === frequencyData.length) {
            upperBucket = 0;
            lowerBucket = frequencyData.length - 1;
        }

        var lowerWeight = (angle - lowerBucket * BUCKET_WIDTH) / BUCKET_WIDTH;
        var upperWeight = 1.0 - lowerWeight;

        energy = adjustedBuckets[lowerBucket] * lowerWeight + adjustedBuckets[upperBucket] * upperWeight;
    }

    if (radius > energy) {
        hsv(0, 0, 0)
        return
    }

    hsv((radius - whiteRadius) / energy * 0.9, 1, 1)
}

export function render3D(index, x, y, z) {
    render2D(index, x, y);
}

function computeAngle(x, y) {
    var originX = x - 0.5;
    var originY = y - 0.5;

    if (!originX && !originY) {
        return 0;
    }

    var angle = atan2(originY, originX);
    if (angle < 0) {
      angle += PI2
    }
    
    return angle;
}

function beatDetected() {
  var freqCeil = maxFrequencyMagnitude || 0.001;
  decay = 0;
  
  for (var bucket = 0; bucket < frequencyData.length; bucket++) {
    adjustedBuckets[bucket] = sqrt(sqrt(frequencyData[bucket] / freqCeil));  
  }
}

// ****************************************************************************
// * SOUND, BEAT AND TEMPO DETECTION  by https://forum.electromage.com/u/jeff *
// ****************************************************************************

var bass, maxBass, bassOn    // Bass and beats
var bassSlowEMA = .001, bassFastEMA = .001 // Exponential moving averages to compare to each other
var bassThreshold = .02      // Raise this if very soft music with no beats is still triggering the beat detector
var maxBass = bassThreshold  // Maximum bass detected recently (while any bass above threshold was present)

var bassVelocitiesSize = 5 // 5 seems right for most. Up to 15 for infrequent bass beats (slower reaction, longer decay), down to 2 for very fast triggering on doubled kicks like in drum n bass
var bassVelocities = array(bassVelocitiesSize) // Circular buffer to store the last 5 first derivatives of the `fast exponential avg/MaxSample`, used to calculate a running average
var lastBassFastEMA = .5, bassVelocitiesAvg = .5
var bassVelocitiesPointer = 0 // Pointer for circular buffer
var bassDebounceTimer = 0

function processBass(delta) {
  // Assume Sensor Board updates at 40Hz (25ms); Max BPM 180 = 333ms or 13 samples; Typical BPM 500ms, 20 samples
  // Kickdrum fundamental 40-80Hz. https://www.bhencke.com/pixelblaze-sensor-expansion
  bass = frequencyData[1] + frequencyData[2] + frequencyData[3]
  maxBass = max(maxBass, bass)
  if (maxBass > 10 * bassSlowEMA && maxBass > bassThreshold) maxBass *= .99 // AGC - Auto gain control
  
  bassSlowEMA = (bassSlowEMA * 999 + bass) / 1000
  bassFastEMA = (bassFastEMA * 9 + bass) / 10

  bassVelocities[bassVelocitiesPointer] = (bassFastEMA - lastBassFastEMA) / maxBass // Normalized first derivative of fast moving expo avg
  bassVelocitiesAvg += bassVelocities[bassVelocitiesPointer] / bassVelocitiesSize
  bassVelocitiesPointer = (bassVelocitiesPointer + 1) % bassVelocitiesSize
  bassVelocitiesAvg -= bassVelocities[bassVelocitiesPointer] / bassVelocitiesSize
  bassOn = bassVelocitiesAvg > .51 // `bassOn` is true when bass is rising

  if (bassOn && bassDebounceTimer <= 0) { 
    beatDetected();
    bassDebounceTimer = 100 // ms
  } else { 
    bassDebounceTimer = max(-3e4, bassDebounceTimer - delta)
  }
  lastBassFastEMA = bassFastEMA
}
8 Likes

Hi there @hbeck, thanks for this pattern!
I’ve been playing around with it for a couple of weeks now and find that it looks quite nice on my 2D and also 1D displays. I’ve been trying to fine-tune the response so the display more accurately represents the beat of the music, but I can’t seem to get that spot-on visual that perfectly matches the beat of the music. Most of the music I like is around 120-130 bpm and I really don’t think I need the display to respond to a syncopated or double time beat.
If you, or anyone else out there, has any tips on which parameters to adjust, I would appreciate some help with that.
Also, I copied and pasted this code into the PB editor, but when I am finished editing and click on “Save”, the program responds with “Saving” but the :no_entry_sign: is also displayed and the edited pattern does not get saved. Can anyone out there explain what I’m doing wrong? :confused:
Thank you.

The not-quite-right beat detection was very much one of the things that made the version posted here a prototype. Beat detection is a hard problem and it might be worth looking around and trying other implementations. I’d love to do the same, but health issues have permanently removed my coding ability for the most part.

Thanks for your quick response to my query, I will check out other patterns that feature beat detection. I really liked your circular equalizer, I just don’t have any coding ability to take it any further.

So sorry to hear of your health issues, please take care, friend.

1 Like