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