Click the chart area to add new harmonics. Explore how the sound and shape of the waveform changes.
Created
October 30, 2016 21:29
-
-
Save mox-1/fca52eb98350d7fe64a0d9c0d573ccc8 to your computer and use it in GitHub Desktop.
Wave synthesis and analysis
This file contains 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
<!DOCTYPE html> | |
<head> | |
<meta charset='utf-8'> | |
<style> | |
svg { | |
padding: 5px; | |
} | |
.axis { | |
stroke: hsl(0, 0%, 70%); | |
} | |
.time-axis, | |
.time-axis path, | |
.time-axis .tick line, | |
.line { | |
stroke: steelblue; | |
fill: none; | |
} | |
.bar:hover { | |
fill: steelblue; | |
} | |
.bar { | |
fill: white; | |
cursor: pointer; | |
} | |
.bar.disabled { | |
cursor: default; | |
} | |
.freq-axis, | |
.freq-axis path, | |
.freq-axis .tick line, | |
.new.line { | |
stroke: #c3c3c3; | |
} | |
.bar.inner { | |
fill: #c3c3c3; | |
} | |
.bar-chart { | |
opacity: 0.1; | |
} | |
</style> | |
</head> | |
<body> | |
<div> | |
<input id='zoom' type='range' value='1' min='0.1' max='1' step='0.01'><label>Time zoom</label> | |
<input id='mute' type='checkbox'><label>Mute</label> | |
</div> | |
<script src='https://d3js.org/d3.v4.min.js'></script> | |
<script> | |
var | |
margin = {top: 40, right: 0, bottom: 40, left: 50}, | |
W = 800, | |
H = 400, | |
π = Math.PI, | |
PERIODS = 4, | |
MAX_FREQ = 10, | |
MAX_AMP = 10, | |
MERGE_DURATION = 800, | |
RESOLUTION = 2 * MAX_FREQ, | |
FUNDAMENTAL_FREQ = 500; | |
var | |
w = W - margin.left - margin.right, | |
h = H - margin.top - margin.bottom, | |
harmonics = [], | |
sliderValue = 1, | |
addHarmonicDisabled = false, | |
audioMuted = false, | |
xScale = d3.scaleLinear() | |
.domain([0, 2 * π * PERIODS]) | |
.range([0, w]), | |
yScale = d3.scaleLinear() | |
.domain([-1 * MAX_AMP, MAX_AMP]) | |
.range([0, h]), | |
heightToAmplitude = d3.scaleLinear() | |
.domain([0, h]) | |
.range([1, 0]); | |
line = d3.line() | |
.x(function(d) { | |
return xScale(d.x); | |
}) | |
.y(function(d) { | |
return yScale(d.y); | |
}) | |
.curve(d3.curveNatural), | |
frequencyAxis = d3.axisBottom( | |
d3.scaleBand() | |
.domain(d3.range(FUNDAMENTAL_FREQ / 1000, (FUNDAMENTAL_FREQ * MAX_FREQ + 1) / 1000, FUNDAMENTAL_FREQ / 1000)) | |
.range([0, w]) | |
), | |
timeAxis = d3.axisTop( | |
xScale.copy().domain([0, PERIODS * 1000 / FUNDAMENTAL_FREQ]) | |
), | |
zoom = d3.select('#zoom'), | |
mute = d3.select('#mute'), | |
mainData = createData(harmonics), | |
barData = d3.range(0, MAX_FREQ).map(function(d) { | |
return h; | |
}), | |
audioCtx = new (window.AudioContext || window.webkitAudioContext)(), | |
oscillator = audioCtx.createOscillator(), | |
gainNode = audioCtx.createGain(); | |
gainNode.gain.value = 0; | |
oscillator.connect(gainNode); | |
gainNode.connect(audioCtx.destination); | |
oscillator.start(); | |
mute.on('change', function(e) { | |
audioMuted = !audioMuted; | |
}) | |
zoom.on('input', function() { | |
if (Math.abs(this.value - sliderValue) > 0.2) { | |
setScale(+this.value, true); | |
} else { | |
setScale(+this.value, false); | |
} | |
sliderValue = this.value; | |
}); | |
var | |
svg = d3.select('body') | |
.append('svg') | |
.attr('width', W) | |
.attr('height', H), | |
g = svg.append('g') | |
.attr('class', 'main') | |
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'), | |
barChart = svg.append('g') | |
.attr('class', 'bar-chart') | |
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')'), | |
bars = barChart.selectAll('rect') | |
.data(barData) | |
.enter().append('g') | |
.attr('class', 'bar') | |
.attr('id', function(d, i) { | |
return i + 1; | |
}) | |
.attr('transform', function(d, i) { return 'translate(' + (i * w / MAX_FREQ) + ',' + 0 + ')'; }) | |
.on('click', function() { | |
if (!addHarmonicDisabled) { | |
var selection = d3.select(this).datum(d3.mouse(this)[1]); | |
addHarmonic(this.id, heightToAmplitude(d3.select(this).data())); | |
!audioMuted && playSynth(); | |
selection.select('.bar.inner') | |
.transition() | |
.duration(MERGE_DURATION) | |
.attr('height', function(d, i) { | |
return h - d; | |
}) | |
.attr('y', function(d, i) { | |
return d; | |
}) | |
} | |
}), | |
mainPath = g.append('path') | |
.attr('class', 'line'); | |
g.append('g') | |
.attr('class', 'time-axis') | |
.call(timeAxis) | |
.append("text") | |
.attr("y", -30) | |
.attr('x', 15) | |
.attr("dy", ".71em") | |
.style("text-anchor", "end") | |
.text("Time (ms)"); | |
g.append('g') | |
.attr('transform', 'translate(0,' + h + ')') | |
.attr('class', 'freq-axis') | |
.call(frequencyAxis) | |
.append("text") | |
.attr("y", 30) | |
.attr('x', 50) | |
.attr("dy", ".71em") | |
.style("text-anchor", "end") | |
.text("Frequency (kHz)"); | |
bars.append('rect') | |
.attr('class', 'bar') | |
.attr('width', w / MAX_FREQ) | |
.attr('height', h) | |
bars.append('rect') | |
.attr('class', 'bar inner') | |
.attr('width', w / MAX_FREQ) | |
.attr('height', function(d, i) { | |
return h - d; | |
}) | |
.attr('y', function(d, i) { | |
return d; | |
}) | |
function addHarmonic(freq, amp) { | |
disableParams(true); | |
var existingHarmonic, | |
doesExist = harmonics.some(function(obj) { | |
if (obj.frequency === freq) { | |
existingHarmonic = obj; | |
return true; | |
} | |
return false; | |
}), | |
newCurve = g.append('path'); | |
if (doesExist) { | |
// ... then immediately redraw main curve without it | |
harmonics = harmonics.filter(function(obj) { | |
return obj.frequency !== freq; | |
}); | |
mainData = createData(harmonics); | |
mainPath.datum(mainData) | |
.transition() | |
.duration(200) | |
.attr('d', line) | |
} | |
function mergeNewCurveWithMain() { | |
newCurve | |
.datum(mainData) | |
.transition() | |
.duration(MERGE_DURATION) | |
.attr('d', line); | |
} | |
function harmonic(d) { | |
return (amp || 1) * Math.sin(d * freq); | |
}; | |
harmonic.frequency = freq; | |
// get new curve to start from its existing amplitude | |
newCurve.datum(createData([existingHarmonic] || [])) | |
.attr('class', 'line new') | |
.attr('d', line); | |
//... then transition to its new one | |
newCurve | |
.datum(createData([harmonic])) | |
.transition() | |
.duration(2000) | |
.attr('d', line) | |
.on('end', function() { | |
harmonics.push(harmonic); | |
// set the main path data... | |
mainData = createData(harmonics); | |
// then transition both paths to it... | |
drawMainPath(); | |
mergeNewCurveWithMain(); | |
}); | |
} | |
function createData(harmonics) { | |
return d3.range(0, (2 * π) * PERIODS + (π / RESOLUTION), (π / RESOLUTION)).map(function(d, i) { | |
return { | |
x: d, | |
y: harmonics.reduce(function(a, b) { | |
if (typeof b === 'function') { | |
return a + b(d) | |
} else { | |
return a; | |
} | |
}, 0) | |
} | |
}); | |
} | |
function drawMainPath() { | |
mainPath.datum(mainData) | |
.transition() | |
.duration(MERGE_DURATION) | |
.attr('d', line) | |
.on('end', function() { | |
d3.select('.line.new').remove(); | |
disableParams(false); | |
}); | |
} | |
function disableParams(disabled) { | |
d3.selectAll('.bar') | |
.classed('disabled', disabled); | |
zoom.attr('disabled', disabled || null); | |
addHarmonicDisabled = disabled; | |
} | |
function setScale(val, click) { | |
xScale.domain([0, val * (2 * π) * PERIODS]); | |
timeAxis = d3.axisTop( | |
xScale.copy().domain([0, val * PERIODS * 1000 / FUNDAMENTAL_FREQ]) | |
); | |
var t = g.transition().duration(click ? 750 : 0); | |
t.select('.time-axis') | |
.call(timeAxis) | |
if(click) { | |
d3.selectAll('.line') | |
.transition() | |
.duration(750) | |
.attr('d', line) | |
} else { | |
d3.selectAll('.line') | |
.attr('d', line) | |
} | |
} | |
function playSynth() { | |
var real = new Float32Array(MAX_FREQ + 1); | |
var imag = new Float32Array(MAX_FREQ + 1); | |
real[0] = 0; | |
bars.data().map(heightToAmplitude).forEach(function(d, i) { | |
real[i + 1] = d; | |
}); | |
var wave = audioCtx.createPeriodicWave(real, imag, {disableNormalization: true}); | |
oscillator.setPeriodicWave(wave); | |
oscillator.frequency.value = FUNDAMENTAL_FREQ; | |
gainNode.gain.setValueAtTime(0, audioCtx.currentTime); | |
gainNode.gain.linearRampToValueAtTime(0.01, audioCtx.currentTime + 1); | |
gainNode.gain.linearRampToValueAtTime(0, audioCtx.currentTime + 3); | |
} | |
</script> | |
</body> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment