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.