I asked a question similar to this earlier, but it did not solve my issue and was explained poorly. This time I've made illustrations to hopefully explain better.
I have a simple frequency spectrum analyser for my audio player. The frequencies are stored in an array that gets updated on each requestAnimationFrame
, the array looks like this:
fbc_array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(fbc_array);
Read more about getByteFrequencyData here.
So this works fine however I would like the frequencies to be evenly spaced throughout the spectrum. Right now it's displaying linear frequencies:
As you can see, the dominating frequency range here is the Treble (High end), and the most dominated frequency range is the bass range (low end). I want my analyser presented with evenly distributed frequency ranges like this:
Here you see the frequencies evenly spaced across the analyser. Is this possible?
The code I used for generating the analyser looks like this:
// These variables are dynamically changed, ignore them.
var canbars = 737
var canmultiplier = 8
var canspace = 1
// The analyser
var canvas, ctx, source, context, analyser, fbc_array, bars, bar_x,
bar_width, bar_height;
function audioAnalyserFrame() {
'use strict';
var i;
canvas.width = $('analyser-').width();
canvas.height = $('analyser-').height();
ctx.imageSmoothingEnabled = false;
fbc_array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(fbc_array);
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
ctx.fillStyle = "white"; // Color of the bars
bars = canbars;
for (i = 0; i < bars; i += canmultiplier) {
bar_x = i * canspace;
bar_width = 2;
bar_height = -3 - (fbc_array[i] / 2);
ctx.fillRect(bar_x, canvas.height, bar_width, bar_height);
}
window.requestAnimationFrame(audioAnalyserFrame);
}
function audioAnalyserInitialize() {
'use strict';
var analyserElement = document.getElementById('analyzer');
if (analyserElement !== null && audioViewIsCurrent() === true) {
if (analyserInitialized === false) {
context = new AudioContext();
source = context.createMediaElementSource(audioSource);
} else {
analyser.disconnect();
}
analyser = context.createAnalyser();
canvas = analyserElement;
ctx = canvas.getContext('2d');
source.connect(analyser);
analyser.connect(context.destination);
if (analyserInitialized === false) {
audioAnalyserFrame();
}
analyserInitialized = true;
analyser.smoothingTimeConstant = 0.7;
}
}
Take note that I am skipping 8 bars (See canmultiplier
at the top) in the for loop (If I don't, the other half of the analyser gets rendered outside the canvas because it's too big.) I don't know if this is also what could be causing the inconsistent frequency ranges.
I asked a question similar to this earlier, but it did not solve my issue and was explained poorly. This time I've made illustrations to hopefully explain better.
I have a simple frequency spectrum analyser for my audio player. The frequencies are stored in an array that gets updated on each requestAnimationFrame
, the array looks like this:
fbc_array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(fbc_array);
Read more about getByteFrequencyData here.
So this works fine however I would like the frequencies to be evenly spaced throughout the spectrum. Right now it's displaying linear frequencies:
As you can see, the dominating frequency range here is the Treble (High end), and the most dominated frequency range is the bass range (low end). I want my analyser presented with evenly distributed frequency ranges like this:
Here you see the frequencies evenly spaced across the analyser. Is this possible?
The code I used for generating the analyser looks like this:
// These variables are dynamically changed, ignore them.
var canbars = 737
var canmultiplier = 8
var canspace = 1
// The analyser
var canvas, ctx, source, context, analyser, fbc_array, bars, bar_x,
bar_width, bar_height;
function audioAnalyserFrame() {
'use strict';
var i;
canvas.width = $('analyser-').width();
canvas.height = $('analyser-').height();
ctx.imageSmoothingEnabled = false;
fbc_array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(fbc_array);
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
ctx.fillStyle = "white"; // Color of the bars
bars = canbars;
for (i = 0; i < bars; i += canmultiplier) {
bar_x = i * canspace;
bar_width = 2;
bar_height = -3 - (fbc_array[i] / 2);
ctx.fillRect(bar_x, canvas.height, bar_width, bar_height);
}
window.requestAnimationFrame(audioAnalyserFrame);
}
function audioAnalyserInitialize() {
'use strict';
var analyserElement = document.getElementById('analyzer');
if (analyserElement !== null && audioViewIsCurrent() === true) {
if (analyserInitialized === false) {
context = new AudioContext();
source = context.createMediaElementSource(audioSource);
} else {
analyser.disconnect();
}
analyser = context.createAnalyser();
canvas = analyserElement;
ctx = canvas.getContext('2d');
source.connect(analyser);
analyser.connect(context.destination);
if (analyserInitialized === false) {
audioAnalyserFrame();
}
analyserInitialized = true;
analyser.smoothingTimeConstant = 0.7;
}
}
Take note that I am skipping 8 bars (See canmultiplier
at the top) in the for loop (If I don't, the other half of the analyser gets rendered outside the canvas because it's too big.) I don't know if this is also what could be causing the inconsistent frequency ranges.
7 Answers
Reset to default 5 +100If I understood you correctly, I think this will work for you, although is far from perfect.
What you are doing in your for loop is to sample the array, once every 8 elements. What I would do is do the sampling in a logarithmic way.
An example:
//Given a range, transforms a value from linear scale to log scale.
var toLog = function(value, min, max){
var exp = (value-min) / (max-min);
return min * Math.pow(max/min, exp);
}
//This would be the frequency array in a linear scale
var arr = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20];
//In this case i'm using a range from 1 to 20, you would use the size of your array. I'm incrementing 'i' by one each time, but you could also change that
for (var i = 1; i < 20; i += 1) {
//I'm starting at 1 because 0 and logarithms dont get along
var logindex = toLog(i,1,19); //the index we want to sample
//As the logindex will probably be decimal, we need to interpolate (in this case linear interpolation)
var low = Math.floor(logindex);
var high = Math.ceil(logindex);
var lv = arr[low];
var hv = arr[high];
var w = (logindex-low)/(high-low);
var v = lv + (hv-lv)*w; //the interpolated value of the original array in the logindex index.
document.write(v + "<br/>"); //In your case you should draw the bar here or save it in an array for later.
}
I hope I explained myself well. Here you have a working demo that has some boundary bugs but it works as I think you need.
I believe I understand what you mean exactly. The problem is not with your code, it is with the FFT underlying getByteFrequencyData
. The core problem is that musical notes are logarithmically spaced while the FFT frequency bins are linearly spaced.
Notes are logarithmically spaced: The difference between consecutive low notes, say A2(110 Hz) and A2#(116.5 Hz) is 6.5 Hz while the difference between the same 2 notes on a higher octave A3(220 Hz) and A3#(233.1 Hz) is 13.1 Hz.
FFT bins are linearly spaced: Say we're working with 44100 samples per second, the FFT takes a window of 1024 samples (a wave), and multiplies it first with a wave as long as 1024 samples (let's call it wave1), so that would be a period of 1024/44100=0.023 seconds
which is 43.48 Hz
, and puts the resulting amplitude in the first bin. Then it multiplies it with a wave with frequency of wave1 * 2, which is 86.95 Hz
, then wave1 * 3 = 130.43 Hz
. So the difference between the frequencies is linear; it's always the same = 43.48, unlike the difference in musical notes which changes.
This is why close low frequencies will be bundled up in the same bin while close high frequencies are separated. This is the problem with FFT's frequency resolution. It can be solved by taking windows bigger than 1024 samples, but that would be a trade off for the time resolution.
To me, it looks like you can simply space out the bars by multiplying the x position of the current bar by the term 10/i. I'm unsure if this is correct but seems so. Octave changes are spaced out evenly in the graph, which is correct.
See my version of a Fourier Series visualizer, which also renders a fft analyzer of the generated audio signal: https://editor.p5js.org/mohragk/sketches/BkMiw4KxV
The analyzer code is in drawAnalyser()
.
You'll have to manually average the values (or something like that) to turn it into a logarithmic array; that's just the way the FFT algorithm works.
Another approach that may or may not work. Break the signal into, say 5 bands. Apply a lowpass and highpass filters and 3 bandpass filters that covers the entire frequency range. Modulate the output of all the filters (except the lowpass) to down 0 frequency. Add an analyser for each of 5 different signals. Plot the response from each of these, taking into account that you've shifted the filter outputs down in frequency.
The individual analyser outputs will still be uniform, but perhaps the result is close enough.
(Modulating down to 0 frequency can be done using a gain node or two whose gain is a sine or cosine wave from an oscillator node.)
Something along the lines of this should work:
// These variables are dynamically changed, ignore them.
var canbars = 737
var canmultiplier = 8
var canspace = 1
// The analyser
var canvas, ctx, source, context, analyser, fbc_array, bars, bar_x,
bar_width, bar_height;
function audioAnalyserFrame() {
'use strict';
var i;
canvas.width = $('analyser-').width();
canvas.height = $('analyser-').height();
ctx.imageSmoothingEnabled = false;
fbc_array = new Uint8Array(analyser.frequencyBinCount);
analyser.getByteFrequencyData(fbc_array);
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
ctx.fillStyle = "white"; // Color of the bars
bars = canbars;
//Find the center
var center = Math.round(bars / 2) - 1;
for (i = 0; i < fbc_array.length; i ++) {
// Update the spectrum bars, spread evenly.
bar_x = (center + (i % 2 == 0 ? -1 : 1) * Math.round(i / 2));
bar_width = 2;
bar_height = -3 - (fbc_array[i] / 2);
ctx.fillRect(bar_x, canvas.height, bar_width, bar_height);
}
window.requestAnimationFrame(audioAnalyserFrame);
}
function audioAnalyserInitialize() {
'use strict';
var analyserElement = document.getElementById('analyzer');
if (analyserElement !== null && audioViewIsCurrent() === true) {
if (analyserInitialized === false) {
context = new AudioContext();
source = context.createMediaElementSource(audioSource);
} else {
analyser.disconnect();
}
analyser = context.createAnalyser();
canvas = analyserElement;
ctx = canvas.getContext('2d');
source.connect(analyser);
analyser.connect(context.destination);
if (analyserInitialized === false) {
audioAnalyserFrame();
}
analyserInitialized = true;
analyser.smoothingTimeConstant = 0.7;
}
}
One step improved, wrap the "update" in a function
function audioAnalyserFrame() {
'use strict';
var i;
canvas.width = $('analyser-').width();
canvas.height = $('analyser-').height();
ctx.imageSmoothingEnabled = false;
fbc_array = new Uint8Array(analyser.frequencyBinCount);
ctx.clearRect(0, 0, canvas.width, canvas.height); // Clear the canvas
ctx.fillStyle = "white"; // Color of the bars
bars = canbars;
//Find the center
var center = Math.round(bars / 2) - 1;
(update = function() {
window.requestAnimationFrame(update);
analyser.getByteFrequencyData(fbc_array);
for (i = 0; i < fbc_array.length; i++) {
// Update the spectrum bars, spread evenly.
bar_x = (center + (i % 2 == 0 ? -1 : 1) * Math.round(i / 2));
bar_width = 2;
bar_height = -3 - (fbc_array[i] / 2);
ctx.fillRect(bar_x, canvas.height, bar_width, bar_height);
}
}();
}
I'm trying to do the same thing, make a frequency visualizer that looks more like an EQ in a DAW:
Here is some TypeScript code I've written for this purpose, though I've written it generically so it could be used for other things.
It takes an array of values that are assumed to correspond to linearly-spaced intervals between linMin
to linMax
, and converts them to values that correspond logarithmically-spaced intervals between logMin
to logMax
, interpolating the values.
/** convert linearly spaced array values to log spaced */
export const logSpace = (
array: number[],
linMin: number,
linMax: number,
logMin: number,
logMax: number,
) => {
/** generate evenly spaced linear steps */
const linRange = linMax - linMin;
const linSteps = Array(array.length)
.fill(0)
.map((_, index) => linMin + linRange * (index / (array.length - 1)));
/** generate evenly spaced log steps */
logMin = Math.log(logMin);
logMax = Math.log(logMax);
const logRange = logMax - logMin;
const logSteps = Array(array.length)
.fill(0)
.map((_, index) =>
Math.exp(logMin + logRange * (index / (array.length - 1))),
);
let lower = 0;
/** for each log step */
return logSteps.map((logStep) => {
/** find pair of linear steps that log step falls between */
while (linSteps[lower]! < logStep && lower < linSteps.length - 2) lower++;
const upper = lower + 1;
const lowerLin = linSteps[lower]!;
const upperLin = linSteps[upper]!;
/** how far along between lin pair log step is */
const percent = clamp((logStep - lowerLin) / (upperLin - lowerLin), 0, 1);
/** interpolate array values */
return array[lower]! + percent * (array[upper]! - array[lower]!);
});
};
(Note: I originally had a base
argument, and used that in place of Math.log
and Math.exp
, but I found that it didn't affect the results, so the base must cancel out in the equations somehow.)
// fake sample data
const array = Array(10)
.fill(0)
.map((_, index) => index);
// [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
// lin steps: [0, 2450, 4900, 7350, 9800, 12250, 14700, 17150, 19600, 22050]
// log steps: [19.99, 43.55, 94.86, 206.61, 449.98, 980.03, 2134.43, 4648.62, 10124.33, 22049.99]
// 22050: half typical sample rate and upper range of human hearing
// 20: lower range of human hearing
logSpace(array, 0, 22050, 20, 22050);
// [0.0081, 0.017, 0.038, 0.084, 0.18, 0.40, 0.87, 1.89, 4.13, 8.99]
Also keep in mind that humans don't perceive all frequencies at the same loudness. So in my visualizer, I reduce lower values with a curve/equation that resembles that wikipedia chart.
A bit faster but less readable (imo) version:
/** convert linearly spaced array values to log spaced */
export const logSpace = (
array: number[],
linMin: number,
linMax: number,
logMin: number,
logMax: number,
) => {
/** pre-compute as much as possible */
const steps = array.length - 1;
const linRange = linMax - linMin;
const linMultiple = linRange / (array.length - 1);
logMin = Math.log(logMin);
logMax = Math.log(logMax);
const logRange = logMax - logMin;
return array.map((_, index) => {
/** evenly spaced log step */
const log = Math.exp(logMin + logRange * (index / steps));
/** find pair of linear steps log step falls between */
const lower = clamp(
Math.floor((log - linMin) / linMultiple),
0,
array.length - 2,
);
const upper = lower + 1;
const lowerLin = linMin + linRange * (lower / steps);
const upperLin = linMin + linRange * (upper / steps);
/** how far along between lin pair log step is */
const percent = clamp((log - lowerLin) / (upperLin - lowerLin), 0, 1);
/** interpolate values */
return array[lower]! + percent * (array[upper]! - array[lower]!);
});
};