From dd4c1edc85331abb9d06ab1f2b557a20bc2a13b1 Mon Sep 17 00:00:00 2001 From: Yotam Mann Date: Fri, 13 Jan 2017 16:42:31 -0500 Subject: [PATCH] fixing stuck notes --- static/src/keyboard/Element.js | 159 +++++++++++++++++++++++++++++++ static/src/keyboard/Keyboard.js | 162 ++++++++++++++++++++++++++++++++ static/src/keyboard/Midi.js | 59 ++++++++++++ static/src/keyboard/Note.js | 38 ++++++++ static/src/roll/Roll.js | 131 ++++++++++++++++++++++++++ static/src/roll/RollNote.js | 44 +++++++++ 6 files changed, 593 insertions(+) create mode 100644 static/src/keyboard/Element.js create mode 100644 static/src/keyboard/Keyboard.js create mode 100644 static/src/keyboard/Midi.js create mode 100644 static/src/keyboard/Note.js create mode 100644 static/src/roll/Roll.js create mode 100644 static/src/roll/RollNote.js diff --git a/static/src/keyboard/Element.js b/static/src/keyboard/Element.js new file mode 100644 index 0000000..82f6363 --- /dev/null +++ b/static/src/keyboard/Element.js @@ -0,0 +1,159 @@ +/** + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import events from 'events' +import 'style/keyboard.css' +import 'pepjs' +import {Roll} from 'roll/Roll' +import {Note} from 'keyboard/Note' + +const offsets = [0, 0.5, 1, 1.5, 2, 3, 3.5, 4, 4.5, 5, 5.5, 6] + +class KeyboardElement extends events.EventEmitter { + + constructor(container, lowest=36, octaves=4){ + super() + this._container = document.createElement('div') + this._container.id = 'keyboard' + container.setAttribute('touch-action', 'none') + container.appendChild(this._container) + + //some default menu stuff + container.addEventListener('pointerup', (e) => delete this._pointersDown[e.pointerId]) + container.addEventListener('contextmenu', this._absorbEvent.bind(this)) + + this._keys = {} + + this._pointersDown = {} + + this.resize(lowest, octaves) + + Roll.appendTo(container) + + this._aiNotes = {} + this._notes = {} + } + + resize(lowest, octaves){ + this._keys = {} + // clear the previous ones + this._container.innerHTML = '' + // each of the keys + const keyWidth = (1 / 7) / octaves + for (let i = lowest; i < lowest + octaves * 12; i++){ + let key = document.createElement('div') + key.classList.add('key') + let isSharp = ([1, 3, 6, 8, 10].indexOf(i % 12) !== -1) + key.classList.add(isSharp ? 'black' : 'white') + this._container.appendChild(key) + // position the element + + let noteOctave = Math.floor(i / 12) - Math.floor(lowest / 12) + let offset = offsets[i % 12] + noteOctave * 7 + key.style.width = `${keyWidth * 100}%` + key.style.left = `${offset * keyWidth * 100}%` + key.id = i.toString() + key.setAttribute('touch-action', 'none') + + const fill = document.createElement('div') + fill.id = 'fill' + key.appendChild(fill) + + this._bindKeyEvents(key) + this._keys[i] = key + + } + } + + _absorbEvent(event) { + const e = event || window.event; + e.preventDefault && e.preventDefault(); + e.stopPropagation && e.stopPropagation(); + e.cancelBubble = true; + e.returnValue = false; + return false; + } + + _bindKeyEvents(key){ + + key.addEventListener('pointerover', (e) => { + if (this._pointersDown[e.pointerId]){ + const noteNum = parseInt(e.target.id) + this.emit('keyDown', noteNum) + } else { + key.classList.add('hover') + } + }) + key.addEventListener('pointerout', (e) => { + if (this._pointersDown[e.pointerId]){ + const noteNum = parseInt(e.target.id) + this.emit('keyUp', noteNum) + } else { + key.classList.remove('hover') + } + }) + key.addEventListener('pointerdown', (e) => { + const noteNum = parseInt(e.target.id) + // this.keyDown(noteNum, false) + this.emit('keyDown', noteNum) + this._pointersDown[e.pointerId] = true + }) + key.addEventListener('pointerup', (e) => { + const noteNum = parseInt(e.target.id) + // this.keyUp(noteNum, false) + this.emit('keyUp', noteNum) + delete this._pointersDown[e.pointerId] + }) + + // cancel all the pointer events to prevent context menu which keeps the key stuck + key.addEventListener('touchstart', this._absorbEvent.bind(this)) + key.addEventListener('touchend', this._absorbEvent.bind(this)) + key.addEventListener('touchmove', this._absorbEvent.bind(this)) + key.addEventListener('touchcancel', this._absorbEvent.bind(this)) + } + + keyDown(noteNum, ai=false){ + // console.log('down', noteNum, ai) + if (this._keys.hasOwnProperty(noteNum)){ + const key = this._keys[noteNum] + key.classList.remove('hover') + + const note = new Note(key.querySelector('#fill'), ai) + + const noteArray = ai ? this._aiNotes : this._notes + if (!noteArray[noteNum]){ + noteArray[noteNum] = [] + } + noteArray[noteNum].push(note) + } + } + + keyUp(noteNum, ai=false){ + // console.log('up', noteNum, ai) + if (this._keys.hasOwnProperty(noteNum)){ + const noteArray = ai ? this._aiNotes : this._notes + if (!(noteArray[noteNum] && noteArray[noteNum].length)){ + // throw new Error('note off without note on') + // setTimeout(() => this.keyUp.bind(this, noteNum, ai), 100) + console.warn('note off before note on') + } else { + noteArray[noteNum].shift().noteOff() + } + } + } +} + +export {KeyboardElement} \ No newline at end of file diff --git a/static/src/keyboard/Keyboard.js b/static/src/keyboard/Keyboard.js new file mode 100644 index 0000000..f6dfdac --- /dev/null +++ b/static/src/keyboard/Keyboard.js @@ -0,0 +1,162 @@ +/** + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import AudioKeys from 'audiokeys' +import Tone from 'Tone/core/Tone' +import events from 'events' +import {KeyboardElement} from 'keyboard/Element' +import buckets from 'buckets-js' +import {Midi} from 'keyboard/Midi' +import Buffer from 'Tone/core/Buffer' + +class Keyboard extends events.EventEmitter{ + constructor(container){ + super() + + this._container = container + + this._active = false + + /** + * The audio key keyboard + * @type {AudioKeys} + */ + this._keyboard = new AudioKeys({polyphony : 88, rows : 1, octaveControls : false}) + this._keyboard.down((e) => { + this.keyDown(e.note) + this._emitKeyDown(e.note) + }) + this._keyboard.up((e) => { + this.keyUp(e.note) + this._emitKeyUp(e.note) + }) + + /** + * The piano interface + */ + this._keyboardInterface = new KeyboardElement(container, 48, 2) + this._keyboardInterface.on('keyDown', (note) => { + this.keyDown(note) + this._emitKeyDown(note) + }) + this._keyboardInterface.on('keyUp', (note) => { + this.keyUp(note) + this._emitKeyUp(note) + }) + + window.addEventListener('resize', this._resize.bind(this)) + //size initially + this._resize() + + //make sure they don't get double clicked + this._currentKeys = {} + + //a queue of all of the events + this._eventQueue = new buckets.PriorityQueue((a, b) => b.time - a.time) + this._boundLoop = this._loop.bind(this) + this._loop() + + const bottom = document.createElement('div') + bottom.id = 'bottom' + container.appendChild(bottom) + + //the midi input + this._midi = new Midi() + this._midi.on('keyDown', (note) => { + this.keyDown(note) + this._emitKeyDown(note) + }) + this._midi.on('keyUp', (note) => { + this.keyUp(note) + this._emitKeyUp(note) + }) + } + + _loop(){ + requestAnimationFrame(this._boundLoop) + const now = Tone.now() + while(!this._eventQueue.isEmpty() && this._eventQueue.peek().time <= now){ + const event = this._eventQueue.dequeue() + event.callback() + } + + } + + _emitKeyDown(note){ + if (this._active){ + this.emit('keyDown', note) + } + } + + _emitKeyUp(note){ + if (this._active){ + this.emit('keyUp', note) + } + } + + keyDown(note, time=Tone.now(), ai=false){ + if (!this._active){ + return + } + if (!this._currentKeys[note]){ + this._currentKeys[note] = 0 + } + this._currentKeys[note] += 1 + this._eventQueue.add({ + time : time, + callback : this._keyboardInterface.keyDown.bind(this._keyboardInterface, note, ai) + }) + } + + keyUp(note, time=Tone.now(), ai=false){ + if (!this._active){ + return + } + //add a little time to it in edge cases where the keydown and keyup are at the same time + time += 0.01 + if (this._currentKeys[note]){ + this._currentKeys[note] -= 1 + this._eventQueue.add({ + time : time, + callback : this._keyboardInterface.keyUp.bind(this._keyboardInterface, note, ai) + }) + } + } + + _resize(){ + const keyWidth = 24 + let octaves = Math.round((window.innerWidth / keyWidth) / 12) + octaves = Math.max(octaves, 2) + octaves = Math.min(octaves, 7) + let baseNote = 48 + if (octaves > 5){ + baseNote -= (octaves - 5) * 12 + } + this._keyboardInterface.resize(baseNote, octaves) + } + + activate(){ + container.classList.add('focus') + this._active = true + } + + deactivate(){ + container.classList.remove('focus') + this._active = false + } +} + +export {Keyboard} \ No newline at end of file diff --git a/static/src/keyboard/Midi.js b/static/src/keyboard/Midi.js new file mode 100644 index 0000000..3d82d54 --- /dev/null +++ b/static/src/keyboard/Midi.js @@ -0,0 +1,59 @@ +/** + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import events from 'events' +import WebMidi from 'webmidi' + +class Midi extends events.EventEmitter{ + constructor(){ + super() + + this._isEnabled = false + + WebMidi.enable((err) => { + if (!err){ + this._isEnabled = true + if (WebMidi.inputs){ + WebMidi.inputs.forEach((input) => this._bindInput(input)) + } + WebMidi.addListener('connected', (device) => { + if (device.input){ + this._bindInput(device.input) + } + }) + } + }) + } + + _bindInput(inputDevice){ + if (this._isEnabled){ + WebMidi.addListener('disconnected', (device) => { + if (device.input){ + device.input.removeListener('noteOn') + device.input.removeListener('noteOff') + } + }) + inputDevice.addListener('noteon', 'all', (event) => { + this.emit('keyDown', event.note.number) + }) + inputDevice.addListener('noteoff', 'all', (event) => { + this.emit('keyUp', event.note.number) + }) + } + } +} + +export {Midi} \ No newline at end of file diff --git a/static/src/keyboard/Note.js b/static/src/keyboard/Note.js new file mode 100644 index 0000000..d4539c5 --- /dev/null +++ b/static/src/keyboard/Note.js @@ -0,0 +1,38 @@ +/** + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {RollNote} from 'roll/RollNote' + +export class Note{ + constructor(container, ai){ + this.element = document.createElement('div') + this.element.classList.add('highlight') + this.element.classList.add('active') + if (ai){ + this.element.classList.add('ai') + } + container.appendChild(this.element) + + this.rollNote = new RollNote(container, ai) + } + noteOff(){ + this.element.classList.remove('active') + this.rollNote.noteOff() + setTimeout(() => { + this.element.remove() + }, 1000) + } +} \ No newline at end of file diff --git a/static/src/roll/Roll.js b/static/src/roll/Roll.js new file mode 100644 index 0000000..5510b0f --- /dev/null +++ b/static/src/roll/Roll.js @@ -0,0 +1,131 @@ +/** + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const THREE = require('three') + +const geometry = new THREE.PlaneGeometry( 1, 1, 1 ) +const material = new THREE.MeshBasicMaterial( {color: 0x1FB7EC, side: THREE.DoubleSide} ) +const aiMaterial = new THREE.MeshBasicMaterial( {color: 0xFFB729, side: THREE.DoubleSide} ) + +window.zero = new THREE.Vector3(0, 0, 0) + +function scale(value, inMin, inMax, min, max){ + return ((value - inMin) / (inMax - inMin)) * (max - min) + min +} + +class RollClass { + constructor(container){ + this._element = document.createElement('div') + this._element.id = 'roll' + + this._camera = new THREE.OrthographicCamera(0, 1, 1, 0, 1, 1000 ) + this._camera.position.z = 1 + this._camera.lookAt(new THREE.Vector3(0, 0, 0)) + + this._scene = new THREE.Scene() + + this._renderer = new THREE.WebGLRenderer({alpha: true}) + this._renderer.setClearColor(0x000000, 0) + this._renderer.setPixelRatio( window.devicePixelRatio ) + this._renderer.sortObjects = false + this._element.appendChild(this._renderer.domElement) + + this._currentNotes = {} + + window.camera = this._camera + + //start the loop + this._lastUpdate = Date.now() + this._boundLoop = this._loop.bind(this) + this._boundLoop() + window.addEventListener('resize', this._resize.bind(this)) + } + + get bottom(){ + return this._element.clientHeight + this._camera.position.y + } + + appendTo(container){ + container.appendChild(this._element) + this._resize() + } + + add(element){ + this._scene.add(element) + } + + keyDown(midi, box, ai=false){ + const selector = ai ? `ai${midi}` : midi + if (!this._currentNotes.hasOwnProperty(selector)){ + this._currentNotes[selector] = [] + } + if (midi && box){ + //translate the box coords to this space + const initialScaling = 10000 + const plane = new THREE.Mesh( geometry, ai ? aiMaterial : material ) + const margin = 4 + const width = box.width - margin * 2 + plane.scale.set(width, initialScaling, 1) + plane.position.z = 0 + plane.position.x = box.left + margin + width / 2 + plane.position.y = this._element.clientHeight + this._camera.position.y + initialScaling / 2 + this._scene.add(plane) + + this._currentNotes[selector].push({ + plane : plane, + position: this._camera.position.y + }) + } + + } + + keyUp(midi, ai=false){ + const selector = ai ? `ai${midi}` : midi + if (this._currentNotes[selector] && this._currentNotes[selector].length){ + const note = this._currentNotes[selector].shift() + const plane = note.plane + const position = note.position + // get the distance covered + plane.scale.y = Math.max(this._camera.position.y - position, 5) + plane.position.y = this._element.clientHeight + position + plane.scale.y / 2 + } + } + + _resize(){ + const frustumSize = 1000 + const aspect = this._element.clientWidth / this._element.clientHeight + //make it match the screen pixesl + this._camera.left = 0 + this._camera.bottom = this._element.clientHeight + this._camera.right = this._element.clientWidth + this._camera.top = 0 + + //update things + this._camera.updateProjectionMatrix() + this._renderer.setSize( this._element.clientWidth, this._element.clientHeight ) + } + + _loop(){ + const delta = Date.now() - this._lastUpdate + this._lastUpdate = Date.now() + requestAnimationFrame(this._boundLoop) + this._renderer.render( this._scene, this._camera ) + this._camera.position.y += 1 / 10 * delta + } +} + +const Roll = new RollClass() +export {Roll} \ No newline at end of file diff --git a/static/src/roll/RollNote.js b/static/src/roll/RollNote.js new file mode 100644 index 0000000..b514409 --- /dev/null +++ b/static/src/roll/RollNote.js @@ -0,0 +1,44 @@ +/** + * Copyright 2016 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const THREE = require('three') +import {Roll} from 'roll/Roll' + +const geometry = new THREE.PlaneBufferGeometry( 1, 1, 1 ) +const material = new THREE.MeshBasicMaterial( {color: 0x1FB7EC, side: THREE.BackSide} ) +const aiMaterial = new THREE.MeshBasicMaterial( {color: 0xFFB729, side: THREE.BackSide} ) + +export class RollNote { + constructor(element, ai){ + this.element = element + const box = this.element.getBoundingClientRect() + const initialScaling = 3000 + this.plane = new THREE.Mesh( geometry, ai ? aiMaterial : material ) + const margin = 4 + const width = box.width - margin * 2 + this.plane.scale.set(width, initialScaling, 1) + this.plane.position.z = 0 + this.plane.position.x = box.left + margin + width / 2 + this.plane.position.y = Roll.bottom + initialScaling / 2 + this.bottom = Roll.bottom + Roll.add(this.plane) + } + noteOff(bottom){ + const dist = Roll.bottom - this.bottom + this.plane.scale.y = Math.max(dist, 5) + this.plane.position.y = this.bottom + this.plane.scale.y / 2 + } +} \ No newline at end of file