Skip to content

Instantly share code, notes, and snippets.

@vlandham
Last active July 20, 2021 02:54
Show Gist options
  • Star 2 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save vlandham/9313904 to your computer and use it in GitHub Desktop.
Save vlandham/9313904 to your computer and use it in GitHub Desktop.
spectrogram

Interactive Spectrogram in the browser!

Same as example in: https://github.com/vlandham/spectrogramJS - but in blocks.

Click 'analyze' to play the sound and create spectrogram.

Uses web audio api.

Also uses D3 with canvas to display the main visualization.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>SpectrogramJS</title>
<style>
canvas, svg {
position: absolute;
top: 0;
left: 0;
}
.spectrogram {
position: relative;
}
.axis {
font: 14px sans-serif;
}
.axis path,
.axis line {
fill: none;
}
.axis line {
shape-rendering: crispEdges;
stroke: #444;
stroke-width: 1.0px;
stroke-dasharray: 2,4;
}
</style>
</head>
<body>
<h1>SpectrogramJS</h1>
<p>Visualize sounds using a spectrogram - right in your browser!</p>
<div id="vis" class="spectrogram"></div>
<p><a href="https://github.com/vlandham/spectrogramJS">See how to use SpectrogramJS</a></p>
<script src="//ajax.googleapis.com/ajax/libs/jquery/2.0.3/jquery.min.js"></script>
<script src="http://d3js.org/d3.v3.min.js"></script>
<script type="text/javascript" src="spectrogram.js"></script>
<script>
var sample = new Spectrogram('bird_short.ogg', "#vis", {width:500, height:200, maxFrequency:8000});
</script>
</body>
</html>
// shim requestAnimFrame for animating playback
window.requestAnimFrame = (function(){
return window.requestAnimationFrame ||
window.webkitRequestAnimationFrame ||
window.mozRequestAnimationFrame ||
window.oRequestAnimationFrame ||
window.msRequestAnimationFrame ||
function( callback ){
window.setTimeout(callback, 1000 / 60);
};
})();
window.AudioContext = window.AudioContext || window.webkitAudioContext;
// helper function for loading one or more sound files
function loadSounds(obj, context, soundMap, callback) {
var names = [];
var paths = [];
for (var name in soundMap) {
var path = soundMap[name];
names.push(name);
paths.push(path);
}
bufferLoader = new BufferLoader(context, paths, function(bufferList) {
for (var i = 0; i < bufferList.length; i++) {
var buffer = bufferList[i];
var name = names[i];
obj[name] = buffer;
}
if (callback) {
callback();
}
});
bufferLoader.load();
}
// class that performs most of the work to load
// a new sound file asynchronously
// originally from: http://chimera.labs.oreilly.com/books/1234000001552/ch02.html
function BufferLoader(context, urlList, callback) {
this.context = context;
this.urlList = urlList;
this.onload = callback;
this.bufferList = new Array();
this.loadCount = 0;
}
BufferLoader.prototype.loadBuffer = function(url, index) {
// Load buffer asynchronously
var request = new XMLHttpRequest();
request.open("GET", url, true);
request.responseType = "arraybuffer";
var loader = this;
request.onload = function() {
// Asynchronously decode the audio file data in request.response
loader.context.decodeAudioData(
request.response,
function(buffer) {
if (!buffer) {
alert('error decoding file data: ' + url);
return;
}
loader.bufferList[index] = buffer;
if (++loader.loadCount == loader.urlList.length)
loader.onload(loader.bufferList);
},
function(error) {
console.error('decodeAudioData error', error);
}
);
}
request.onerror = function() {
alert('BufferLoader: XHR error');
}
request.send();
};
BufferLoader.prototype.load = function() {
for (var i = 0; i < this.urlList.length; ++i)
this.loadBuffer(this.urlList[i], i);
};
// ---
// Spectrogram class
// constructor takes a filename, selector id to use to figure
// out where to display, and a big options hash.
// (not a great api - I know!)
// sets up most of the configuration for the sound analysis
// and then loads the sound using loadSounds.
// Once finished loading, the setupVisual callback
// is called.
// ---
function Spectrogram(filename, selector, options) {
if (!options) {
options = {};
}
this.options = options;
var SMOOTHING = 0.0;
var FFT_SIZE = 2048;
// this.sampleRate = 256;
this.sampleRate = options.sampleSize || 512;
this.decRange = [-80.0, 80.0];
this.width = options.width || 900;
this.height = options.height || 440;
this.margin = {top: 20, right: 20, bottom: 30, left: 50};
this.selector = selector;
this.filename = filename;
this.context = context = new AudioContext();
this.analyser = context.createAnalyser();
this.javascriptNode = context.createScriptProcessor(this.sampleRate, 1, 1);
this.analyser.minDecibels = this.decRange[0];
this.analyser.maxDecibels = this.decRange[1];
this.analyser.smoothingTimeConstant = SMOOTHING;
this.analyser.fftSize = FFT_SIZE;
this.freqs = new Uint8Array(this.analyser.frequencyBinCount);
this.data = [];
this.isPlaying = false;
this.isLoaded = false;
this.startTime = 0;
this.startOffset = 0;
this.count = 0;
this.curSec = 0;
this.maxCount = 0;
loadSounds(this, this.context, {
buffer: this.filename
}, this.setupVisual.bind(this));
}
// ---
// process
// callback executed each onaudioprocess of the javascriptNode.
// performs the work of analyzing the sound and storing the results
// in a big array (not a great idea, but I haven't thought of something
// better.
// ---
Spectrogram.prototype.process = function(e) {
if(this.isPlaying && !this.isLoaded) {
this.count += 1;
this.curSec = (this.sampleRate * this.count) / this.buffer.sampleRate;
this.analyser.getByteFrequencyData(this.freqs);
var d = {'key':this.curSec, 'values':new Uint8Array(this.freqs)};
this.data.push(d);
if(this.count >= this.maxCount) {
this.switchButtonText();
this.togglePlayback();
this.draw();
this.isLoaded = true;
console.log(this.data.length);
console.log(this.data[0].values.length);
}
}
}
// ---
// setupVisual
// callback executed when the sound has been loaded.
// sets up scales and other components needed to visualize.
// ---
Spectrogram.prototype.setupVisual = function() {
console.log(this.context.sampleRate);
// can configure these from the options
this.timeRange = [0, this.buffer.duration];
var maxFrequency = this.options.maxFrequency || this.getBinFrequency(this.analyser.frequencyBinCount / 2);
var minFrequency = this.options.minFrequency || this.getBinFrequency(0);
this.freqRange = [minFrequency, maxFrequency];
this.svg = d3.select(this.selector).append("svg")
.attr("width", this.width + this.margin.left + this.margin.right)
.attr("height", this.height + this.margin.top + this.margin.bottom)
.append("g")
.attr("transform", "translate(" + this.margin.left + "," + this.margin.top + ")");
this.canvas = d3.select(this.selector).append("canvas")
.attr("class", "vis_canvas")
.attr("width", this.width + this.margin.left)
.attr("height", this.height + this.margin.top)
.style("padding", d3.map(this.margin).values().join("px ") + "px");
this.progressLine = this.svg.append("line");
var that = this;
var button_id = this.selector + "_button";
this.button = d3.select(this.selector).append("button")
.style("margin-top", this.height + this.margin.top + this.margin.bottom + 20 + "px")
.attr("id", button_id)
.text("analyze")
.on("click", function() {
that.togglePlayback();
});
var freqs = [];
for(i = 64; i < this.analyser.frequencyBinCount; i += 64) {
freqs.push(d3.round(this.getBinFrequency(i), 4));
}
this.freqSelect = d3.select(this.selector).append("select")
.style("margin-top", this.height + this.margin.top + this.margin.bottom + 20 + "px")
.style("margin-left", "20px")
.on("change", function() {
var newFreq = this.options[this.selectedIndex].value
console.log(newFreq);
that.yScale.domain([0, newFreq]);
that.draw();
});
this.freqSelect.selectAll('option')
.data(freqs).enter()
.append("option")
.attr("value", function(d) { return d;})
.attr("selected", function(d,i) { return (d == 11047) ? "selected" : null;})
.text(function(d) { return d3.round(d / 1000) + "k";});
this.maxCount = (this.context.sampleRate / this.sampleRate) * this.buffer.duration;
this.xScale = d3.scale.linear()
.domain(this.timeRange)
.range([0, this.width]);
this.yScale = d3.scale.linear()
.domain(this.freqRange)
.range([this.height,0]);
this.zScale = d3.scale.linear()
.domain(this.decRange)
.range(["white", "black"])
.interpolate(d3.interpolateLab);
var commasFormatter = d3.format(",.1f");
this.xAxis = d3.svg.axis()
.scale(this.xScale)
.orient("bottom")
.tickSize(-this.height - 15)
.tickPadding(10)
.tickFormat(function(d) {return commasFormatter(d) + "s";});
this.yAxis = d3.svg.axis()
.scale(this.yScale)
.orient("left")
.tickSize(-this.width - 10, 0, 0)
.tickPadding(10)
.tickFormat(function(d) {return d3.round(d / 1000, 0) + "k";});
this.svg.append("g")
.attr("class", "x axis")
.attr("transform", "translate(0," + (this.height + 10) + ")")
.call(this.xAxis);
this.svg.append("g")
.attr("class", "y axis")
.attr("transform", "translate(" + (-10) + ",0)")
.call(this.yAxis)
}
// ---
// showProgress
// ---
Spectrogram.prototype.showProgress = function() {
if(this.isPlaying && this.isLoaded) {
this.curDuration = (this.context.currentTime - this.startTime);
// this.count += 1;
// this.curSec = (this.sampleRate * this.count) / this.buffer.sampleRate;
var that = this;
this.progressLine
.attr("x1", function() {return that.xScale(that.curDuration);})
.attr("x2", function() {return that.xScale(that.curDuration);})
.attr("y1", 0)
.attr("y2", this.height)
.attr("stroke",'red')
.attr("stroke-width", 2.0);
requestAnimFrame(this.showProgress.bind(this));
if(this.curDuration >= this.buffer.duration) {
this.progressLine.attr("y2", 0);
this.togglePlayback()
}
}
}
// ---
// Little helper function to change the text on the button
// after the sound has been analyzed.
// ---
Spectrogram.prototype.switchButtonText = function() {
this.button.text("play");
}
// ---
// Toggle playback
// ---
Spectrogram.prototype.togglePlayback = function() {
if (this.isPlaying) {
this.source.stop(0);
this.startOffset += this.context.currentTime - this.startTime;
console.log('paused at', this.startOffset);
this.button.attr("disabled", null);
} else {
this.button.attr("disabled", true);
this.startTime = this.context.currentTime;
this.count = 0;
this.curSec = 0;
this.curDuration = 0;
this.source = this.context.createBufferSource();
this.source.buffer = this.buffer;
this.analyser.buffer = this.buffer;
this.javascriptNode.onaudioprocess = this.process.bind(this);
// Connect graph
this.source.connect(this.analyser);
this.analyser.connect(this.javascriptNode);
this.source.connect(this.context.destination);
this.javascriptNode.connect(this.context.destination);
this.source.loop = false;
this.source.start(0, this.startOffset % this.buffer.duration);
console.log('started at', this.startOffset);
if (this.isLoaded) {
requestAnimFrame(this.showProgress.bind(this));
}
}
this.isPlaying = !this.isPlaying;
}
// ---
// ---
Spectrogram.prototype.draw = function() {
var that = this;
var min = d3.min(this.data, function(d) { return d3.min(d.values)});
var max = d3.max(this.data, function(d) { return d3.max(d.values)});
this.zScale.domain([min + 20, max - 20]);
this.dotWidth = this.width / this.maxCount;
this.dotHeight = this.height / this.analyser.frequencyBinCount;
var visContext = d3.select(this.selector).select(".vis_canvas")[0][0].getContext('2d');
this.svg.select(".x.axis").call(this.xAxis);
this.svg.select(".y.axis").call(this.yAxis);
visContext.clearRect( 0, 0, this.width + this.margin.left, this.height );
// display as canvas here.
this.data.forEach(function(d) {
for(var i = 0; i < d.values.length - 1; i++) {
var v = d.values[i];
var x = that.xScale(d.key);
var y = that.yScale(that.getBinFrequency(i));
visContext.fillStyle = that.zScale(v);
visContext.fillRect(x,y,that.dotWidth, that.dotHeight);
}
});
}
// ---
// ---
Spectrogram.prototype.getFrequencyValue = function(freq) {
var nyquist = this.context.sampleRate/2;
var index = Math.round(freq/nyquist * this.freqs.length);
return this.freqs[index];
}
// ---
// ---
Spectrogram.prototype.getBinFrequency = function(index) {
var nyquist = this.context.sampleRate/2;
var freq = index / this.freqs.length * nyquist;
return freq;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment