Theremin oscillator using the Web Audio API. Controlled by gyroscope or user interaction. Must view in debug mode to use the gyroscope.
A Pen by J Scott Smith on CodePen.
<div id="root"></div> | |
<a href="https://s.codepen.io/jscottsmith/debug/dRBOzE" rel="noopener" target="_blank" class="fullscreen"> | |
<svg version="1.1" x="0px" y="0px" width="30px" height="30px" viewBox="0 0 30 30"> | |
<polygon fill="#FFF" points="16.5,15 23.1,8.3 26,11.2 26,4 18.8,4 21.7,6.9 15,13.5 8.3,6.9 11.2,4 4,4 4,11.2 6.9,8.3 13.5,15 6.9,21.7 4,18.8 4,26 11.2,26 8.3,23.1 15,16.5 21.7,23.1 18.8,26 26,26 26,18.8 23.1,21.7 "/> | |
</svg> | |
</a> |
/*------------------------------*\ | |
|* Theremin Oscillator | |
\*------------------------------*/ | |
/* | |
* | |
* NOTES: | |
* | |
* Pitch on the theremin is controlled by the pointers x position. | |
* Amplitude is controlled by the pointers y position. | |
* Mousedown or Touch to start the Oscillator. Toggle on/off with osc UI. | |
* Must view in debug mode to use the Gyroscope. Toggle on/off with gyro UI. | |
* | |
**/ | |
/*------------------------------*\ | |
|* Utils / Constants | |
\*------------------------------*/ | |
const FREQ_LOW = 32.7031956625748294; // C1 in Hz | |
const FREQ_HIGH = 1046.502261202394538; // C6 in Hz | |
const DPR = window.devicePixelRatio || 1; | |
function scaleBetween(value, newMin, newMax, oldMin, oldMax) { | |
return (newMax - newMin) * (value - oldMin) / (oldMax - oldMin) + newMin; | |
} | |
/*------------------------------*\ | |
|* UI Icons | |
\*------------------------------*/ | |
const gyroOnIcon = ` | |
<svg version="1.1" width="60px" height="60px" x="0px" y="0px" viewBox="0 0 60 60"> | |
<ellipse fill="none" stroke="#FFF" stroke-width="2" cx="30" cy="30" rx="5.5" ry="18"/> | |
<ellipse fill="none" stroke="#FFF" stroke-width="2" cx="30" cy="30" rx="18" ry="5.5"/> | |
<circle fill="#FFF" cx="30" cy="30" r="1"/> | |
<circle fill="none" stroke="#FFF" stroke-width="2" cx="30" cy="30" r="25"/> | |
</svg>`; | |
const gyroOffIcon = ` | |
<svg version="1.1" width="60px" height="60px" x="0px" y="0px" viewBox="0 0 60 60"> | |
<circle fill="none" stroke="#FFF" stroke-width="2" cx="30" cy="30" r="25"/> | |
<line fill="none" stroke="#FFF" stroke-width="2" x1="19" y1="19" x2="41" y2="41"/> | |
<line fill="none" stroke="#FFF" stroke-width="2" x1="41" y1="19" x2="19" y2="41"/> | |
</svg>`; | |
const oscOn = ` | |
<svg version="1.1" width="60px" height="60px" x="0px" y="0px" viewBox="0 0 60 60"> | |
<path fill="none" stroke="#FFF" stroke-width="2" d="M4,30c0.8,6,1.6,12,3.2,12c3.2,0,3.2-24,6.5-24c3.2,0,3.2,24,6.5,24c3.2,0,3.2-24,6.5-24c3.2,0,3.2,24,6.5,24 c3.2,0,3.2-24,6.5-24c3.2,0,3.2,24,6.5,24c3.3,0,3.3-24,6.5-24c1.6,0,2.4,6,3.3,12"/> | |
<polyline fill="none" stroke="#FFF" stroke-width="2" points="4,13.7 4,4 56,4 56,13.7 "/> | |
<polyline fill="none" stroke="#FFF" stroke-width="2" points="56,46.5 56,56 4,56 4,46.5 "/> | |
</svg>`; | |
const oscOff = ` | |
<svg version="1.1" width="60px" height="60px" x="0px" y="0px" viewBox="0 0 60 60"> | |
<polyline fill="none" stroke="#FFF" stroke-width="2" points="4.8,13.7 4.8,4 56.8,4 56.8,13.7 "/> | |
<polyline fill="none" stroke="#FFF" stroke-width="2" points="56.8,46.5 56.8,56 4.8,56 4.8,46.5 "/> | |
<line fill="none" stroke="#FFF" stroke-width="2" x1="19" y1="19.8" x2="41" y2="41.8"/> | |
<line fill="none" stroke="#FFF" stroke-width="2" x1="41" y1="19.8" x2="19" y2="41.8"/> | |
</svg>`; | |
/*------------------------------*\ | |
|* Theremin Class | |
\*------------------------------*/ | |
class Theremin { | |
constructor(root) { | |
this.root = root; | |
this.setupUI(); | |
this.renderDom(); | |
this.updateDom(); | |
this.x = window.innerWidth / 2 * DPR; | |
this.y = window.innerWidth / 1.8 * DPR; | |
this.w = window.innerWidth; | |
this.h = window.innerHeight; | |
const AudioCtx = window.AudioContext || window.webkitAudioContext; | |
this.audioCtx = new AudioCtx(); | |
this.visualizer = new Visualizer(this); | |
this.setupGyro(); | |
this.setCanvasSize(); | |
this.setupMasterGain(); | |
this.addListeners(); | |
} | |
state = { | |
isPlaying: false, | |
userInteracting: false, // flag for mouse or touch interaction | |
}; | |
setState(nextState) { | |
this.state = Object.assign({}, this.state, nextState); | |
this.updateDom(); | |
} | |
setupUI() { | |
this.canvas = document.createElement('canvas'); | |
this.playButton = document.createElement('button'); | |
this.playButton.className = 'play-btn'; | |
this.gyroButton = document.createElement('button'); | |
this.gyroButton.className = 'gyro-btn'; | |
} | |
setupGyro() { | |
// Gyro | |
const gn = new GyroNorm(); | |
const args = { | |
frequency: 50, // ( How often the object sends the values - milliseconds ) | |
gravityNormalized: true, // ( If the gravity related values to be normalized ) | |
orientationBase: GyroNorm.GAME, | |
decimalCount: 3, // ( How many digits after the decimal point will there be in the return values ) | |
logger: null, // ( Function to be called to log messages from gyronorm.js ) | |
screenAdjusted: false, // ( If set to true it will return screen adjusted values. ) | |
}; | |
gn | |
.init(args) | |
.then(() => { | |
gn.start(this.handleGyro); | |
}) | |
.catch(e => { | |
console.warn( | |
'Error: Device does not support DeviceOrientation or DeviceMotion is not supported by the browser or device' | |
); | |
}); | |
} | |
addListeners() { | |
['mousedown', 'touchstart'].forEach(event => { | |
this.canvas.addEventListener( | |
event, | |
this.handleInteractStart, | |
false | |
); | |
}); | |
['mouseup', 'touchend'].forEach(event => { | |
this.canvas.addEventListener(event, this.handleInteractEnd, false); | |
}); | |
['mousemove', 'touchmove'].forEach((event, touch) => { | |
this.canvas.addEventListener(event, this.handleInteractMove, false); | |
}); | |
window.addEventListener('resize', this.handlerResize, false); | |
this.playButton.addEventListener('click', this.handlePlayButton, false); | |
this.gyroButton.addEventListener('click', this.handleGyroButton, false); | |
} | |
setupMasterGain() { | |
console.log('master gain setup') | |
this.masterGainNode = this.audioCtx.createGain(); | |
this.masterGainNode.connect(this.audioCtx.destination); | |
this.masterGainNode.gain.value = 0; | |
} | |
setGain(value) { | |
// cancel any future value | |
const currentTime = this.audioCtx.currentTime; | |
this.masterGainNode.gain.cancelScheduledValues(currentTime); | |
this.masterGainNode.gain.value = value; | |
} | |
updateGain(nextGain, cb) { | |
if (typeof nextGain === "undefined") { | |
nextGain = scaleBetween(this.y, 0, 1, 0, this.h); | |
} | |
const rampTime = 0.1; | |
const prevGain = this.masterGainNode.gain.value; | |
const currentTime = this.audioCtx.currentTime; | |
const endTime = currentTime + rampTime; | |
this.masterGainNode.gain.cancelScheduledValues(currentTime); | |
this.masterGainNode.gain.setValueAtTime(prevGain, currentTime); | |
this.masterGainNode.gain.linearRampToValueAtTime(nextGain, endTime); | |
// probably a nicer way to do this without callback nonsense... | |
if (typeof cb === 'function') { | |
if (this.gainTimeout) { | |
clearTimeout(this.gainTimeout); | |
} | |
this.gainTimeout = setTimeout(() => { | |
this.gainTimeout = null; | |
cb() | |
}, rampTime * 1000); | |
} | |
} | |
setFreq() { | |
const frequency = scaleBetween(this.x, FREQ_LOW, FREQ_HIGH, 0, this.w); | |
this.osc.frequency.value = frequency; | |
} | |
setCanvasSize() { | |
this.w = window.innerWidth * DPR; | |
this.h = window.innerHeight * DPR; | |
this.canvas.width = this.w; | |
this.canvas.height = this.h; | |
this.canvas.style.width = window.innerWidth + 'px'; | |
this.canvas.style.height = window.innerHeight + 'px'; | |
} | |
// Interaction and Event Handlers | |
handlerResize = () => { | |
this.setCanvasSize(); | |
}; | |
handleInteractStart = e => { | |
e.stopPropagation(); | |
if (!this.state.userInteracting) { | |
this.setState({ | |
userInteracting: true, | |
}); | |
} | |
if (this.state.isPlaying) { | |
this.stop(); | |
} else { | |
this.play(); | |
} | |
}; | |
handleInteractEnd = () => { | |
this.stop(); | |
}; | |
handlePlayButton = (e) => { | |
e.stopPropagation(); | |
this.state.isPlaying ? this.stop() : this.play(); | |
}; | |
handleGyroButton = () => { | |
this.setState({ | |
userInteracting: !this.state.userInteracting, | |
}); | |
}; | |
handleGyro = data => { | |
if (this.state.userInteracting) return; | |
this.x = scaleBetween(data.do.gamma, 0, this.w, -90, 90); | |
this.y = scaleBetween(data.do.beta, 0, this.w, -45, 45); | |
this.updateOsc(); | |
}; | |
handleInteractMove = (event, touch) => { | |
if (!this.state.userInteracting) { | |
this.setState({ | |
userInteracting: !this.state.userInteracting, | |
}); | |
} | |
if (event.targetTouches) { | |
event.preventDefault(); | |
this.x = event.targetTouches[0].clientX * DPR; | |
this.y = event.targetTouches[0].clientY * DPR; | |
} else { | |
this.x = event.clientX * DPR; | |
this.y = event.clientY * DPR; | |
} | |
this.updateOsc(); | |
}; | |
// Audio Playback | |
play = () => { | |
if (this.osc) { | |
this.osc.stop(); | |
this.osc = null; | |
} | |
if (this.gainTimeout) return; // wait for any fading gains | |
this.setState({ | |
isPlaying: true, | |
}); | |
this.updateGain(); | |
// new osc | |
this.osc = this.audioCtx.createOscillator(); | |
this.setFreq(); | |
this.osc.connect(this.masterGainNode); | |
this.osc.start(); | |
return this.osc; | |
}; | |
stop = () => { | |
this.updateGain(0, () => { | |
this.setState({ | |
isPlaying: false, | |
}); | |
this.osc.stop(); | |
this.osc = null; | |
}); | |
}; | |
updateOsc() { | |
if (this.osc && !this.gainTimeout) { | |
this.updateGain(); | |
this.setFreq(); | |
} | |
} | |
// DOM View | |
renderDom() { | |
this.root.appendChild(this.canvas); | |
this.root.appendChild(this.playButton); | |
this.root.appendChild(this.gyroButton); | |
} | |
updateDom() { | |
this.playButton.innerHTML = this.state.isPlaying ? oscOff : oscOn; | |
this.gyroButton.innerHTML = this.state.userInteracting | |
? gyroOnIcon | |
: gyroOffIcon; | |
} | |
} | |
/*------------------------------*\ | |
|* Visualizer | |
\*------------------------------*/ | |
class Visualizer { | |
constructor(theremin) { | |
this.theremin = theremin; | |
this.ctx = this.theremin.canvas.getContext('2d'); | |
this.ctx.scale(DPR, DPR); | |
this.tick = 0; | |
this.draw(); | |
} | |
drawOsc() { | |
const xAxis = this.theremin.h / 2; | |
this.ctx.beginPath(); | |
this.ctx.lineJoin = 'round'; | |
this.ctx.lineWidth = 2 * DPR; | |
this.ctx.strokeStyle = '#222'; | |
this.ctx.moveTo(0, xAxis); | |
// Draw Oscillator or flat line | |
if (this.theremin.osc) { | |
const phase = this.tick * Math.PI / 180 * this.theremin.w / 8; | |
const amplitude = | |
this.theremin.masterGainNode.gain.value * this.theremin.h / 4; | |
const frequency = | |
this.theremin.osc.frequency.value / | |
this.theremin.w * | |
this.theremin.w / | |
10; | |
const step = 1; | |
const c = this.theremin.w / Math.PI / (frequency * 2); | |
for (let i = 0; i < this.theremin.w; i += step) { | |
const y = amplitude * Math.sin(i / c + phase); | |
this.ctx.lineTo(i, xAxis + y); | |
} | |
this.ctx.stroke(); | |
} else { | |
this.ctx.lineTo(this.theremin.w, xAxis); | |
this.ctx.stroke(); | |
} | |
} | |
drawBackground() { | |
const { x, y, w, h } = this.theremin; | |
const w2 = w / 2; | |
const h2 = h / 2; | |
// this.ctx.fillStyle = '#FFFFFF'; | |
// this.ctx.fillRect(0, 0, w, h); | |
const r1 = Math.floor(scaleBetween(y, 50, 155, h, 0)); | |
const g1 = Math.floor(scaleBetween(y, 200, 50, h, 0)); | |
const b1 = Math.floor(scaleBetween(x, 100, 255, 0, w)); | |
const r2 = Math.floor(scaleBetween(y, 95, 255, 0, h)); | |
const g2 = Math.floor(scaleBetween(y, 155, 95, 0, h)); | |
const b2 = Math.floor(scaleBetween(x, 95, 255, 0, w)); | |
const color1 = `rgb(${r1}, ${g1}, ${b1})`; | |
const color2 = `rgb(${r2}, ${g2}, ${b2})`; | |
const r = Math.max(w, h) * 2; | |
const grad1 = this.ctx.createRadialGradient(w2, h2, r, x, y, 0); | |
grad1.addColorStop(0, color1); | |
grad1.addColorStop(1, color2); | |
this.ctx.fillStyle = grad1; | |
this.ctx.fillRect(0, 0, w, h); | |
} | |
drawPoint() { | |
const r1 = 16 * DPR; | |
const r2 = 2 * DPR; | |
this.ctx.lineWidth = 2 * DPR; | |
this.ctx.strokeStyle = '#222'; | |
this.ctx.beginPath(); | |
this.ctx.arc( | |
this.theremin.x, | |
this.theremin.y, | |
r1, | |
0, | |
Math.PI * 2, | |
true | |
); | |
this.ctx.closePath(); | |
this.ctx.stroke(); | |
this.ctx.fillStyle = '#FFFFFF'; | |
this.ctx.beginPath(); | |
this.ctx.arc( | |
this.theremin.x, | |
this.theremin.y, | |
r2, | |
0, | |
Math.PI * 2, | |
true | |
); | |
this.ctx.closePath(); | |
this.ctx.fill(); | |
} | |
drawText() { | |
const ms = Math.min(this.theremin.w, this.theremin.h); | |
const size = ms / 12; | |
this.ctx.font = `900 italic ${size}px futura-pt, futura, sans-serif`; | |
this.ctx.textAlign = 'center'; | |
this.ctx.fillStyle = 'white'; | |
const copy = 'Theremin Oscillator'; | |
this.ctx.fillText(copy, this.theremin.w / 2, this.theremin.h / 2 + size / 3); | |
} | |
// Animation Loop | |
draw = () => { | |
this.drawBackground(); | |
this.drawOsc(); | |
this.drawText(); | |
this.drawPoint(); | |
++this.tick; | |
window.requestAnimationFrame(this.draw); | |
}; | |
} | |
const root = document.getElementById('root'); | |
const theremin = new Theremin(root); |
<script src="https://s3-us-west-2.amazonaws.com/s.cdpn.io/105988/gyronorm.js"></script> |
html, body { | |
width: 100%; | |
height: 100%; | |
overflow: hidden; | |
} | |
button { | |
z-index: 1; | |
font-size: 1rem; | |
font-family: Futura, helvetica; | |
text-transform: uppercase; | |
letter-spacing: 0.1rem; | |
font-weight: bold; | |
color: #fff; | |
border: none; | |
outline: none; | |
background: transparent; | |
cursor: pointer; | |
} | |
.play-btn { | |
position: fixed; | |
top: 1rem; | |
right: 50%; | |
margin-right: -40px; | |
} | |
.gyro-btn { | |
position: fixed; | |
bottom: 1rem; | |
right: 50%; | |
margin-right: -40px; | |
} | |
.fullscreen { | |
position: fixed; | |
bottom: 2rem; | |
right: 2rem; | |
} | |
canvas { | |
cursor: none; | |
} |
Theremin oscillator using the Web Audio API. Controlled by gyroscope or user interaction. Must view in debug mode to use the gyroscope.
A Pen by J Scott Smith on CodePen.