Last active
June 30, 2023 06:16
-
-
Save hkolbeck/9d9332f1a8f86ac8f1b5637d2b48a3d0 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
Circular Equalizer | |
Copyright (C) 2023 Hannah Kolbeck | |
This program is free software: you can redistribute it and/or modify | |
it under the terms of the GNU General Public License as published by | |
the Free Software Foundation, either version 3 of the License, or | |
(at your option) any later version. | |
This program is distributed in the hope that it will be useful, | |
but WITHOUT ANY WARRANTY; without even the implied warranty of | |
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
GNU General Public License for more details. | |
You should have received a copy of the GNU General Public License | |
along with this program. If not, see <http://www.gnu.org/licenses/>. | |
*/ | |
export var frequencyData; | |
export var energyAverage, maxFrequency; | |
var hArr = array(pixelCount + 1); | |
BUCKET_WIDTH = PI2 / frequencyData.length; | |
export var rotationRate = 0.0 | |
export var rotation = 0.0 | |
export var 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; | |
} | |
export function sliderRotation(v) { | |
rotationRate = PI * v / 100 | |
} | |
export var maxFrequencyMagnitude = -1 | |
soundLevelVal = 0 | |
pic = makePIController(.05, .35, 30, 0, 400) | |
soundLevelPowFactor = 1.2 | |
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] | |
} | |
var msSinceUpdate = 0; | |
export function beforeRender(delta) { | |
processBass(delta); | |
rotation = (rotation + rotationRate) % PI2 | |
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]; | |
} 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 + rotation) % PI2 ; | |
} | |
function beatDetected() { | |
decay = 0; | |
var sensitivity = calcPIController(pic, .5 - soundLevelVal) | |
soundLevelVal = pow(maxFrequencyMagnitude * sensitivity, | |
soundLevelPowFactor) | |
for (var bucket = 0; bucket < frequencyData.length; bucket++) { | |
adjustedBuckets[bucket] = sqrt(sqrt(frequencyData[bucket] / soundLevelVal)); | |
} | |
var maxBucket = -1 | |
var maxAdjusted = -1 | |
for (var bucket = 0; bucket < adjustedBuckets.length; bucket++) { | |
if (adjustedBuckets[bucket] > maxAdjusted) { | |
maxBucket = bucket | |
maxAdjusted = adjustedBuckets[bucket] | |
} | |
} | |
adjustedBuckets[maxBucket] *= 0.6 | |
} | |
// **************************************************************************** | |
// * 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 = 2 // 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 | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment