Skip to content

Instantly share code, notes, and snippets.

@vasturiano
Last active October 1, 2024 04:05
Show Gist options
  • Save vasturiano/e70e14483fe01eb0a3ea7d1d46a30571 to your computer and use it in GitHub Desktop.
Save vasturiano/e70e14483fe01eb0a3ea7d1d46a30571 to your computer and use it in GitHub Desktop.
Musical Hexagons
license: mit
height: 800

A two-dimensional spatial arrangement of the chromatic musical notes. Contrary to the traditional piano keyboard, this is a fully relative layout, equivalent intervals are always equidistant to each other.

The corresponding note is played by hovering over the keys, using the browser's Web Audio API. Use the selectors to specify the interval of adjacent notes in either dimension, and modify the tesselation pattern.

Project developed during D3 unconf 2016.

<head>
<link rel="stylesheet" href="style.css">
<script src="//cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.18.1/babel.min.js"></script>
<script src="//cdnjs.cloudflare.com/ajax/libs/d3/4.2.0/d3.min.js"></script>
<script src="//unpkg.com/midiutils@0.0.4/src/MIDIUtils.js"></script>
</head>
<body>
<div>
Horizontal: <select id="hor-select"></select>
Diagonal: <select id="diag-select"></select>
</div>
<svg id="canvas"></svg>
<script type="text/babel" src="index.js"></script>
</body>
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]
}
}
body {
text-align: center;
font-family: Sans-serif;
}
.hex path {
stroke: darkslategrey;
stroke-width: 0;
opacity: 0.85;
}
.hex.highlight path {
opacity: 1;
stroke-width: 2;
}
.hex text, .legend-hex text {
fill: #333;
pointer-events: none;
}
.hex text {
font-size: 14px;
}
.legend-hex text {
font-size: 11px;
}
.legend-hex path {
fill: #CCE;
}
.legend-hex.central-hex path {
fill: rebeccapurple;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment