Skip to content

Instantly share code, notes, and snippets.

@harunpehlivan
Created July 20, 2021 13:59
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save harunpehlivan/caa7df1e380e647c53097c1d479f7896 to your computer and use it in GitHub Desktop.
Save harunpehlivan/caa7df1e380e647c53097c1d479f7896 to your computer and use it in GitHub Desktop.
Shepard Piano
<div class="ctrl">
<label>
<div>Octave loop </div>
<input type="number" name="loop" min="1" max="7" value="1" /></label>
<label>
<div>Repeat keyboard</div>
<input type="number" name="repeat" min="1" max="5" value="3" /></label>
<label>
<div>Fifth</div>
<input type="range" name="fifth" min="0" max="1" value="0" step="0.01" />
</label>
<label>
<div>Third</div>
<input type="range" name="third" min="0" max="1" value="0" step="0.01" />
</label>
<label>
<div>Slide</div>
<input type="range" name="slide" min="0" max="0.25" value="0" step="0.01" />
</label>
</div>
const svgNS = "http://www.w3.org/2000/svg"
const svg = document.createElementNS(svgNS, "svg")
const group = document.createElementNS(svgNS, "g")
const input = {}
const value = {}
let pointerdown = false
Array.from(document.querySelectorAll(".ctrl input")).forEach(inp => {
let val
const name = inp.name
input[name] = inp
inp.addEventListener('input', change)
inp.addEventListener('change', () => inp.value = val)
change()
function change() {
val = Math.min(+inp.max, Math.max(+inp.value, +inp.min))
const step = +inp.step || 1
val = Math.round(val / step) * step
value[name] = val
}
})
const diam1 = 150
const diam2 = 215
const diam3 = 300
const whites = [0, null, 1, null, 2, 3, null, 4, null, 5, null, 6]
const keyNumber = new WeakMap()
let octaveCount = 3
let octaveLoop = 3
input.loop.addEventListener('input', function() {
value.repeat = input.repeat.value = 1
})
group.classList.add("keyboard")
function angle(turn) {
const rad = turn * Math.PI * 2
const cos = Math.cos(rad)
const sin = Math.sin(rad)
return {
rad,
cos,
sin
}
}
function sharpPos(note) {
if (note <= 5)
return note * 3 / 7 / 5
else
return 3 / 7 + (note - 5) * 4 / 7 / 7
}
function draw() {
octaveLoop = value.loop
octaveCount = octaveLoop * input.repeat.value
for (let octave = 0; octave < octaveCount; octave++) {
for (let i = 0; i < 12; i++) {
const white = whites[i]
const a1 = angle((octave + sharpPos(i)) / octaveCount)
const a2 = angle((octave + sharpPos(i + 1)) / octaveCount)
const elm = document.createElementNS(svgNS, "path")
if (i === 0) {
elm.classList.add('ut')
if (octave % octaveLoop === 0) {
elm.classList.add('first-ut')
}
}
let path = `\
M ${a1.cos * diam2} ${a1.sin * diam2}\
L ${a1.cos * diam3} ${a1.sin * diam3}\
A ${diam3} ${diam3} 0 0 1 ${a2.cos * diam3},${a2.sin * diam3}\
L ${a2.cos * diam2},${a2.sin * diam2}`
if (white == null) {
path += `A ${diam2} ${diam2} 0 0 0 ${a1.cos * diam2} ${a1.sin * diam2}`
elm.classList.add("sharp")
} else {
const a0 = angle((octave + (white / 7)) / octaveCount)
const a3 = angle((octave + ((white + 1) / 7)) / octaveCount)
path += `\
A ${diam2} ${diam2} 0 0 1 ${a3.cos * diam2},${a3.sin * diam2}\
L ${a3.cos * diam1},${a3.sin * diam1}\
A ${diam1} ${diam1} 0 0 0 ${a0.cos * diam1},${a0.sin * diam1}\
L ${a0.cos * diam2},${a0.sin * diam2}\
A ${diam2} ${diam2} 0 0 1 ${a1.cos * diam2},${a1.sin * diam2}`
}
elm.setAttribute("d", path)
group.appendChild(elm)
keyNumber.set(elm, (i + octave * 12) % (12 * octaveLoop))
}
}
svg.appendChild(group)
document.body.appendChild(svg)
}
draw()
input.loop.addEventListener("input", draw)
input.repeat.addEventListener("input", draw)
function resize() {
const vw = window.innerWidth
const vh = window.innerHeight
const min = Math.min(vw, vh)
const scale = min / diam3 / 2
const transform = `translate(${vw / 2}, ${vh / 2}) scale(${scale})`
group.setAttribute('transform', transform)
}
resize()
window.addEventListener("resize", resize)
group.addEventListener('pointerdown', e => {
ac.resume()
pointerdown = true
play(keyNumber.get(e.target))
})
window.addEventListener('pointerup', () => {
pointerdown = false
stop()
})
group.addEventListener('pointerover', e => pointerdown && play(keyNumber.get(e.target)))
group.addEventListener('pointerleave', stop)
const ac = new AudioContext()
const volume = ac.createGain()
let wave
let oldNote = null
volume.gain.value = 0
volume.connect(ac.destination)
const osc = ac.createOscillator()
function createWave() {
const max = 13
const real = new Float32Array(Math.pow(2, max))
for (let i = 0; i < max; i += octaveLoop) {
real[Math.pow(2, i)] = 1
real[Math.pow(2, i) * 3] = value.fifth
real[Math.pow(2, i) * 5] = value.third
}
wave = ac.createPeriodicWave(real, real)
osc.setPeriodicWave(wave)
}
input.loop.addEventListener("input", createWave)
input.fifth.addEventListener("input", createWave)
input.third.addEventListener("input", createWave)
createWave()
osc.connect(volume)
osc.start()
function play(note) {
volume.gain.value = .3
if (oldNote === null || value.slide === 0) {
const freq = 440 * (Math.pow(2, (note - 9) / 12)) / 32
osc.frequency.setValueAtTime(freq, ac.currentTime)
oldNote = note
return
}
const note2 = note
if (oldNote !== null) {
while (note - oldNote > 6)
note -= 12
while (note - oldNote < -6)
note += 12
}
const oldFreq = 440 * (Math.pow(2, (oldNote - 9) / 12)) / 32
const freq = 440 * (Math.pow(2, (note - 9) / 12)) / 32
osc.frequency.cancelScheduledValues(ac.currentTime)
osc.frequency.setValueAtTime(oldFreq, ac.currentTime)
osc.frequency.linearRampToValueAtTime(freq, ac.currentTime + value.slide)
oldNote = note2
}
function stop() {
volume.gain.value = 0
oldNote = null
}
html
padding 0
margin 0
width 100%
height 100%
background-color #4
color #f
body
padding 0
margin 0
width 100%
height 100%
.ctrl
position absolute
//background-color #c
top 0
left 0
right 0
bottom 0
margin auto
width 125px
height 225px
text-align center
label
display block
input
box-sizing border-box
width 50px
text-align center
color #0
border 1px solid black
svg
height 100%
width 100%
vertical-align top
.keyboard
//transform translate(50%, 50%)
path
stroke black
fill white
cursor pointer
vector-effect non-scaling-stroke
&.sharp
fill black
&.ut
fill #faa
&.first-ut
fill #f44
&:hover
fill #888
&:active path:hover
fill #cdf
filter drop-shadow(0 0 20px #acff)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment