const topMargin = 35, hexR = 30, nLevels = 13, // 12 semitones + octave centralFreq = 261.6, // C4 defaultHorizSemitones = 2, // Major 2nd defaultDiagonalSemitones = 7 // Perfect 5th const noteScale = d3.scaleOrdinal(d3.schemeCategory20c) .domain(['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']) // Init webaudio const audioCtx = new (window.AudioContext || window.webkitAudioContext)() initStatic() d3Digest() // function initStatic() { // Build DOM d3.select('svg#canvas') .attr('width', window.innerWidth) .attr('height', window.innerHeight - topMargin) .append('g') .attr('id', 'interval-legend') .attr('transform', `translate(${window.innerWidth},${window.innerHeight - topMargin})`) const horSelect = d3.select('select#hor-select') const diagSelect = d3.select('select#diag-select') for (let i=0; i<=12; i++) { const name = getIntervalName(i).long horSelect.append('option') .attr('value', i) .attr('selected', i === defaultHorizSemitones ? true : null) .text(name) diagSelect.append('option') .attr('value', i) .attr('selected', i === defaultDiagonalSemitones ? true : null) .text(name) } horSelect.on('change', d3Digest) diagSelect.on('change', d3Digest) } function d3Digest() { const transitionTime = 900, hexPath = getPolygonPath(hexR, 6, Math.PI / 2) let gain, oscillator let hexs = d3.select('#canvas') .selectAll('.hex') .data(genHexList(hexR, [window.innerWidth / 2, (window.innerHeight - topMargin) / 2], centralFreq, nLevels), d => d.id) // Old hexs hexs.exit().transition().duration(transitionTime) // Shrink and fade-out .attr('transform', d => `translate(${d.x},${d.y}) scale(0)`) .style('opacity', 0) .remove() // New hexs const newHexs = hexs.enter().append('g') .attr('class', 'hex') .attr('transform', d => `translate(${d.x},${d.y}) scale(0)`) // Scale/fade-in new hexs .style('opacity', 0) newHexs.append('path') .attr('d', hexPath) .style('fill', d => noteScale(d.name.slice(0, -1))) newHexs.append('text') .attr('text-anchor', 'middle') .attr('dy', '.35em') .text(d => d.name) newHexs .on('mouseenter', function(d) { d3.select(this).classed('highlight', true) gain = audioCtx.createGain() gain.connect(audioCtx.destination) oscillator = audioCtx.createOscillator() oscillator.connect(gain) //oscillator.type = 'sine' oscillator.frequency.value = d.freq oscillator.start() }) .on('mouseleave', function() { d3.select(this).classed('highlight', false) // Fade out if (gain) { gain.gain.setTargetAtTime(0, audioCtx.currentTime, 0.25) } if (oscillator) { oscillator.stop(audioCtx.currentTime + 2) } }) // Update all hexs.merge(newHexs).transition().duration(transitionTime) .attr('transform', d => `translate(${d.x},${d.y})`) .style('opacity', 1) updLegend() // function updLegend() { const centralNoteNumber = MIDIUtils.frequencyToNoteNumber(centralFreq), hexR = 12, hexPath = getPolygonPath(hexR, 6, Math.PI / 2) let legendHex = d3.select('#interval-legend') .selectAll('.legend-hex') .data(genHexList(hexR, [-hexR * 7, -hexR * 7], centralFreq, 4)) // New hexs const newLegendHex = legendHex.enter() .append('g') .classed('legend-hex', true) newLegendHex.append('path').attr('d', hexPath) newLegendHex.append('text') .attr('text-anchor', 'middle') .attr('dy', '.35em') // Update legendHex = legendHex.merge(newLegendHex) legendHex .classed('central-hex', d => MIDIUtils.frequencyToNoteNumber(d.freq) === centralNoteNumber) .attr('transform', d => `translate(${d.x},${d.y})`) .select('text') .text(d => { const intervalNum = MIDIUtils.frequencyToNoteNumber(d.freq) - centralNoteNumber return `${intervalNum < 0 ? '-' : ''}${getIntervalName(Math.abs(intervalNum)).short}` }) } function genHexList(r, centerXy, centralFreq, levels) { levels += (levels % 2) ? 0 : 1 // Round up to nearest odd number const {horSemitones, diagSemitones} = getSelectedIntervals(), diagonalUpSemitones = diagSemitones, diagonalDownSemitones = horSemitones - diagSemitones, leftFreq = centralFreq * getIntervalRatio(-horSemitones * (levels - 1) / 2), leftXy = centerXy let noteCnt = {} // Keep track of which notes are added to assign a unique ID to each leftXy[0] -= (levels - 1) * r // Left side of the row // Central row let hexs = buildRow(r, leftXy, leftFreq, levels) d3.range(1, (levels - 1) / 2 + 1).forEach(i => { const offset = [i * r * 2 * Math.cos(Math.PI / 3), i * r * 2 * Math.sin(Math.PI / 3)] hexs.push( // Up-right ...buildRow(r, [leftXy[0] + offset[0], leftXy[1] - offset[1]], leftFreq * getIntervalRatio(i * diagonalUpSemitones), levels - i), // Down-right ...buildRow(r, [leftXy[0] + offset[0], leftXy[1] + offset[1]], leftFreq * getIntervalRatio(i * diagonalDownSemitones), levels - i) ) }) return hexs // function buildRow(r, xy, freq, levels) { const hexs = [], horizInterval = getIntervalRatio(horSemitones) let carryX = xy[0], carryFreq = freq while (levels) { const noteNum = MIDIUtils.frequencyToNoteNumber(carryFreq) if (noteNum>=12 && noteNum <= 126) { // Ignore notes below C0 (12) or above F#9 (126) const noteName = MIDIUtils.noteNumberToName(noteNum).replace(/-/, '') // Assign unique id (noteName + counter) if (!noteCnt.hasOwnProperty(noteName)) noteCnt[noteName] = 0 noteCnt[noteName]++ const id = `${noteName}-${noteCnt[noteName]}` hexs.push({ x: carryX, y: xy[1], freq: carryFreq, name: noteName, id: id }) } carryX += r * 2 carryFreq *= horizInterval levels-- } return hexs } function getIntervalRatio(numSemitones) { // Equal temperament return Math.pow(2, numSemitones / 12) } } function getPolygonPath(r, nSides, startAngle) { let d = '' d3.range(nSides).map(side => { const angle = startAngle + 2 * Math.PI * side / nSides return [r * Math.cos(angle), r * Math.sin(angle)] }).forEach(pt => { d += (d.length ? 'L' : 'M') + pt.join(',') }) return d + 'Z' } function getSelectedIntervals() { return { horSemitones: d3.select('#hor-select').node().value, diagSemitones: d3.select('#diag-select').node().value } } } function getIntervalName(semitones) { const shortNames = ['', 'b2', '2', 'b3', '3', '4', 'b5', '5', 'b6', '6', 'b7', 'M7', 'oct', 'b9', '9', 'b10', '10', '11', '#11', '12', 'b13', '13', 'b14', '14', '2-oct'], longNames = ['Unisson', 'Minor 2nd', 'Major 2nd', 'Minor 3rd', 'Major 3rd', 'Perfect 4th', 'Tritone', 'Perfect 5th', 'Minor 6th', 'Major 6th', 'Minor 7th', 'Major 7th', 'Octave', 'Minor 9th', 'Major 9th', 'Minor 10th', 'Major 10th', '11', '#11th', '12', 'Minor 13', 'Major 13', 'Minor 14', 'Major 14', 'Double Octave'] return { short: shortNames[semitones], long: longNames[semitones] } }