Skip to content

Instantly share code, notes, and snippets.

@mox-1
Created October 30, 2016 21:29
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 mox-1/fca52eb98350d7fe64a0d9c0d573ccc8 to your computer and use it in GitHub Desktop.
Save mox-1/fca52eb98350d7fe64a0d9c0d573ccc8 to your computer and use it in GitHub Desktop.
Wave synthesis and analysis

Click the chart area to add new harmonics. Explore how the sound and shape of the waveform changes.

<!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