Spectrum Analyser pattern

I’ve made a 24x16 matrix of LEDs that I’ve been having fun creating lots of patterns for. Here’s one that’s a spectrum analyser with peak level indicators and automatic gain. I’m not quite sure I have the auto gain fully correct, though it does seem to work OK in my testing so far. Also note that currently the code requires the width of the matrix to be explicitly defined. Any feedback or suggestions for improvements appreciated, otherwise feel free to use this in your own projects and let me know how it goes!

// Get frequency info from the sensor expansion board
export var frequencyData

width = 24
height = pixelCount / width

peaks = array(width)  // peak values for each bar, in the range [0, height)
fy = array(width)     // current frequency values for each bar, in the range [0, height)
peakDropMs = 0

// Automatic gain / PIController
targetMax = 0.9       // aim for a maximum bar of 90% full
averageMax = 0.0      // approx rolling average of the maximum bar, for feedback into the PIController
pic = makePIController(0.25, 1.8, 30, 0, 200)

function makePIController(kp, ki, start, min, max) {
  var pic = array(5)
  pic[0] = kp
  pic[1] = ki
  pic[2] = start
  pic[3] = min
  pic[4] = max
  return pic
}

function calcPIController(pic, err) {
  pic[2] = clamp(pic[2] + err, pic[3], pic[4])
  return pic[0] * err + pic[1] * pic[2]
}

export function beforeRender(delta) {
  // Calculate sensitivity based on how far away we are from our target maximum
  sensitivity = max(1, calcPIController(pic, targetMax - averageMax))

  t1 = time(.01)
  
  peakDropMs += delta

  // Drop all the peaks every 100ms
  if (peakDropMs > 100) {
      peakDropMs = 0
      for (i = 0; i < width; i++) {
        peaks[i] -= 1
      }
    }
  
  currentMax = 0.0
  for (i = 0; i < width; i++) {
    logy = log(i / width + 1)
    // Determine the portion of the bar that is filled based on the current sound level.
    // We use the PIController sensitivity to try and keep this at the targetMax
    powerLevel = frequencyData[logy * 32] * sensitivity
    fy[i] = floor(min(1, powerLevel) * (height - 1))
    peaks[i] = max(peaks[i], fy[i])
    currentMax = max(currentMax, powerLevel)
  }
  averageMax = averageMax - (averageMax / 50) + (currentMax / 50)
}

export function render2D(index, x, y) {
  yPixel = floor(y * (width - 1) + 0.5)
  xPixel = height - 1 - floor(x * (height - 1) + 0.5)

  h = t1 + y - floor(t1 + y)
  s = peaks[yPixel] == xPixel ? 0 : 1
  v = fy[yPixel] > xPixel || peaks[yPixel] == xPixel ? 1 : 0
  hsv(h, s, v)
}
4 Likes

So I woke up today and decided today was the day I was finally going to port my old Arduino spectrum analyzer code over to PixelBlaze, so I opened my PixelBlaze editor and thought no, wait, I’ll check the forums real quick first. Then I find this was posted just 30 minutes prior. Took me a while to get the Mapper working (never tried it before) and rotated properly but it all works. Great post! Thanks for sharing!

3 Likes

Thanks for sharing this I will have a play later, not being the greatest at coding fun stuff like this is always a bonus and really appreciate, thank you. Tony

1 Like

This post makes me want to build a matrix!

Very cool @ChrisNZ! I can’t wait to try this out! Would you like the honor of publishing it to the pattern site? I think this will be a popular pattern for sure :slight_smile:

Thanks for sharing!

Thanks everyone! I’d be happy to publish it on the pattern site but I was hoping to make a few changes and improvements first based on any feedback, plus a couple of ideas I still have. If I publish it now can it be updated later, or is it better I wait until it’s a bit more polished?

I’m very pleased to hear Sunandmooncouture managed to get it working OK. Your comment about getting the rotation right made me realise my variables in render2D() are around the wrong way for the axis which no doubt you found confusing - I’ll fix that! (and I guess my Mapper logic needs correcting too)

One thing I don’t like is that when multiple bars clip past the maximum, all of their peak level indicators then drop back parallel with each other. That looks a bit awkward to me, so I was planning to try out a couple of other approaches to try and stagger them a bit more.

One other thing I was thinking of improving is the colouring of the bars so they’re a bit more interesting than the current generic rainbow.

I only get time to work on this at the weekends unfortunately, but hopefully I can get most of the above (along with any other suggestions?) done this Saturday.

Hi Chris!

This is really cool. I’ve been playing with it on a 32 wide by 8 high. It was pretty easy to follow.

Like you mentioned, I swapped the axes as you’ll see in my snippets below. Second, I see how you’re calculating the yPixel to the center frequency, but when you apply the floor I think it’s equivalent to this:

  xPixel = floor(x * width)
  yPixel = height - 1 - floor(y * height)

You can leave our your ternaries if you like - PB casts comparators to 1 and 0. So:
  peaks[yPixel] == xPixel ? 1 : 0

is equivalent to:

  peaks[yPixel] == xPixel

I also noticed my top row was only being triggered by the peaks, not the frequencyData. I changed this line in `beforeRender()`:
    fy[i] = floor(min(1, powerLevel) * (height - 1))

to this:

    fy[i] = floor(min(1, powerLevel) * height)

and then started to see a fuller vertical range.

To verify the issue and change, I commented out the peaks in render():

v = fy[xPixel] > yPixel // || peaks[xPixel] == yPixel

I also wanted the peaks to go away when everything is quiet - they were hanging around the bottom row for me. So I changed this:
    peaks[i] = max(peaks[i], fy[i])

to:

    peaks[i] = max(peaks[i], fy[i] - 1)

Here's the final output:

I’m pretty sure that what I see going on in the low end with a sine wave tone is a combo of:

  1. Laptop speakers’ poor low end response
  2. You can see harmonics pretty clearly, maybe from physical resonances in the laptop and sensor board.
  3. The low end FFTs are the most sensitive to having the tone centered.

Thanks for posting!

3 Likes

Thanks for all the helpful fixes and improvements Jeff, really appreciated and they have saved me a fair bit of time today solving some of the very same issues! I’ve incorporated them into what I have plus fixed the x/y mapping logic. I’ll pulish what I have to the pattern site as it’s hopefully in good enough shape now for people to use as-is, or as a starting point for their own customisations.

Cheers!
Chris

1 Like

Hey folks! I’ve been lurking around this site for a few months and am finally taking a stab at using Pixelblaze.
I’m code illiterate, so I’m wondering how do I actually invert the spectrum? I used a controller a while back that came with 3 different spectrum patterns.
One was normal from bottom with peaks at top, the other was from top with peaks at bottom and the third and my favorite pattern, the spectrum started from the center and basically mirrored itself up and down, from center.
Here’s a video of that spectrum effect starting from the center:

Anyhow, I’m gonna phase out using that controller with those 3 spectrum effects and switch to pixelblaze. Working the matrix out was so darn easy! I just need to figure out how to properly invert the spectrum and also have it start from the center, outwards.

This video is how the spectrum ended up looking with that old controller at our camp’s stage at Burning Man a few year’s ago:

Thank you so much wizards!

2 Likes

I didn’t have time to customize this a part of my normal PB work, but today @SkyPirate reached out directly needing to get this done - he’s generously offered to share that work with everyone.

Here’s the code to fit his install, an impressive 3,600 LEDs across 2 PBs in sync mode with output expanders. I was able to eek out 23 FPS with some caching. Sync mode across 2 PBs let him double the frame rate for this large spectrum analyzer. I love how we can subdivide the compute now.

Code
/*
  Spectrum Analyzer with Centered base extending both up and down
  Based on the "Sound - spectrum analyzer 1D/2D" default pattern that comes with PB
  
  Optimized for use with line-in on the Sensor Board (on the leader)
  
  Custom work - One leader controller with 1800 LEDs in 3 vertical columns, for the LF bands
  indexed from the ground up, and L->R. 
  
  One follower controller with another 1800 LEDs in the next 3 vertical columns, for the HF bands
  
  Use Output Expenders to boost framerate - and spread across more controllers
  
  Produced for Efrain by Jeff Vyduna / ngnr.org
  License granted to Efrain Vega De Varona: MIT License
  License for all others: https://creativecommons.org/licenses/by-nc-sa/2.0/deed.en 
*/

// PIXEL MAPS

/*  
// Left side map - low frequencies
function (pixelCount) {
  width = 3
  var map = []
  for (i = 0; i < pixelCount; i++) {
    x = 100* Math.floor(i / (pixelCount/width))
    y = i % (pixelCount / width)
    map.push([x, y])
  }
  map.push([550, 0], [-50, 0]) // Scale extents
  return map
}

// Right side map - high frequencies
function (pixelCount) {
  width = 3
  var map = []
  for (i = 0; i < pixelCount; i++) {
    x = 100* Math.floor(i / (pixelCount/width))
    y = i % (pixelCount / width)
    map.push([x, y])
  }
   map.push([-350, 0], [250, 0]) // Scale extents
  return map
}
*/


// Set this to the width of your 2D display, the number of vertical frequency bars to plot
width = 3 // Number of columns per Pixelblaze. Be sure your map is installed in each.
height = pixelCount / width / 2 // /2 to mirror the height downward in this centered display

// Get frequency information from the sensor expansion board
export var frequencyData = array(32)

// Slide this up to calm down the meter on lineIn when the level is silent or very low. 
// This affects higher bins more since they contian less audio energy in general.
var minThreshold = 0.000336
export function sliderSilenceLevel(_v) {
  minThreshold = _v * .0008
}

// Automatic gain / PI controller. See comments in "sound - blinkfade".
targetMax = .9       // Aim for a maximum bar of 90% full
// Slide this to scale the target amount of a max bar. It will adapt ovr 5-15 seconds of sound data.
export function sliderFill(_v) {
  targetMax = _v
}

var peakDropPct = .1 // Drop Peaks every frame by this percentage of the distance between last peak and current value
// Slide this to scale how fast the peaks drop
export function sliderPeakDropSpeed(_v) {
  peakDropPct = .02 + .4 * _v
}


// Peak y values (in units of pixels) for each bar, in the range 0..`height`
var peaks = array(width)
// Current frequency values for each bar, in the range 0..`height`
var fy = array(width) 

// Approx rolling average of the maximum bar, for feedback into the PIController
averageMax = 0
export var pic = makePIController(.25, 1.8, 30, 0, 100)

function makePIController(kp, ki, start, min, max) {
  var pic = array(5)
  pic[0] = kp
  pic[1] = ki
  pic[2] = start
  pic[3] = min
  pic[4] = max
  return pic
}

function calcPIController(pic, err) {
  pic[2] = clamp(pic[2] + err, pic[3], pic[4])
  return pic[0] * err + pic[1] * pic[2]
}

// // Find the x coordinates of each band bar given the x's - assumes even spacing.
// No longer using because I precache XY indices.
// var minX = 1.1, maxX = -.1
// // Assumes 2D map
// mapPixels((i, x, y, z) => {
//   minX = min(x, minX)
//   maxX = max(x, maxX)
// })


// Apply averaging for Efrain's low number of bars (6) to average more frequency bins.
// Each bar will average the frequency bin values from binRanges[bar] to binRanges[bar+1]-1
var binRanges = [0, 2, 4, 7, 11, 16, 22]

// Debug to see log ranges
// export var logys = array(7)
// for (i=0; i<7; i++) {
//   logys[i] = log(i/6 + 1)*32
// }

export function beforeRender(delta) {
  // Calculate sensitivity based on how far away we are from our target maximum
  sensitivity = max(1, calcPIController(pic, targetMax - averageMax))

  hueScroll = time(8 / 65.536)/5  // hue rotation period. First number is seconds to rotate a full rainbow

  // Drop all the peaks by a percentage of the distance to the current bar's level
  for (i = 0; i < width; i++) {
    peaks[i] = floor(peaks[i] - (peaks[i] - fy[i]) * peakDropPct)
  }
  
  currentMax = 0
  for (i = 0; i < width; i++) {
    var bar = i + nodeId() * width // 0 - 5
    // custom for Efrain - 2 nodes, lower freq = nodeId == 0, higher bins => nodeId == 1
    
    // Determine the portion of the bar filled based on the current sound level.
    // We use the PIController sensitivity to try and keep this at the targetMax
    // orig: 
    //   logy = log(bar / (width*2) + 1) // Plot lower bins (log of 2 = bottom 30%)
    //   powerLevel = frequencyData[logy * 32] * sensitivity
    // Improved for Efrain's:
    var powerLevel = 0
    for (var bin = binRanges[bar]; bin < binRanges[bar+1] ; bin++) {
      powerLevel += max(frequencyData[bin] - minThreshold, 0)
    }
    // Average across number of bins summed, then scale by the PI controller's output
    powerLevel = powerLevel / (binRanges[bar+1] - binRanges[bar]) * sensitivity
    
    fy[i] = floor(min(1, powerLevel) * height)

    peaks[i] = max(peaks[i], fy[i] - 1)

    currentMax = max(currentMax, powerLevel)
  }
  averageMax = averageMax - (averageMax / 50) + (currentMax / 50)
  
  // Shift space so y map range in -.5-.5, to assist with using Y for color control
  resetTransform()
  translate(0, -.5) 
}


// Boost framerate by precaching x/y indices for this specific large-#LED install 
var xPCache = array(pixelCount)
var yPCache = array(pixelCount)
var satCache = array(pixelCount) // Saturation - white towards extremes
for (var i = 0; i < pixelCount; i++) {
  xPCache[i] = floor(i / (pixelCount / width))
  yPCache[i] = abs(300 - (i % (height * 2)) - 1)
  satCache[i] = pow(1 - (yPCache[i] / height), .25)
}

hues = array(width)
if (nodeId() == 0) {  // left side bar hues
  hues = [0, .98, .93] 
} else if (nodeId() == 1) { // right side bar hues
  hues = [.88, .82, .74]
} else {
  // Undefined / unexpected follower nodeId() for this 2-controller install
}
 
export function render2D(index, x, y) {
  // xPixel = floor((x-minX) / (maxX + 0.01 - minX) * width)  // Converts 0..1 'world units' x into pixel width
  // yPixel = floor(abs(y-.5) * 2 * height) // y == .5 => yPixel == 0
  
  // Optimized for Efrian's specific install to boost framerate
  xPixel = xPCache[index]
  yPixel = yPCache[index]

  // h = .6 + hueScroll + x/4 + abs(y)/4 // Cycle the bar color through blue-red. Divide by a larger number to get less variation in colors across bars
  h = hues[xPixel] // Use pre-planned hues for each bar, set above per controller node.

  v = fy[xPixel] > yPixel  // Fill bars from 0..fy[xPixel]
  
  // If this is a peak pixel, apply a peak color
  if (peaks[xPixel] == yPixel) { h = 0; v = 1 }
  
  hsv(h, satCache[index], v)
}

This is also uploaded to the Patterns library as “SkyPirate’s Centered Spectrum”

4 Likes