UNPKG

@theoryofnekomata/piano-keyboard

Version:

Simple piano keyboard in JavaScript

762 lines (638 loc) 26.2 kB
/** * Script description. * @author TheoryOfNekomata * @date 2017-02-10 */ (function (root, name, dependencies, factory) { var Component = function Oatmeal(deps) { return (root[name] = factory.apply(null, deps)); }; if (typeof define === 'function' && define.amd) { define(dependencies, function () { return new Component( Array.prototype.slice.call(arguments) ); }); return; } if (typeof module === 'object' && module.exports) { module.exports = new Component( dependencies.map(function (depName) { return require(depName); }) ); return; } return new Component( dependencies.map(function (depName) { return root[depName]; }) ); })(this, 'PianoKeyboard', [], function pianoKeyboard() { //noinspection MagicNumberJS var // enum for pitch classes PitchClass = { GSharp: 0, A: 1, ASharp: 2, B: 3, C: 4, CSharp: 5, D: 6, DSharp: 7, E: 8, F: 9, FSharp: 10, G: 11 }, // all the pitch classes in a single octave pitchClassCount = Object.keys(PitchClass).length, // only the white keys' count in a single octave whiteKeyPitchClassCount = Object.keys(PitchClass).filter(function (key) { return key.indexOf('Sharp') < 0; }).length, // default start key grandPianoStartKey = 1, // default end key grandPianoEndKey = 88, // default velocity on playing in the keyboard keyboardVelocity = 100, // maximum velocity maxVelocity = 127, // adjustment for black key positioning blackKeyRatioAdjustment = 0.015, // key bindings bindings = { 'standard': { 81: 40, 50: 41, 87: 42, 51: 43, 69: 44, 82: 45, 53: 46, 84: 47, 54: 48, 89: 49, 55: 50, 85: 51, 73: 52, 57: 53, 79: 54, 48: 55, 80: 56, 219: 57, 187: 58, 221: 59, 90: 28, 83: 29, 88: 30, 68: 31, 67: 32, 86: 33, 71: 34, 66: 35, 72: 36, 78: 37, 74: 38, 77: 39, 188: 40, 76: 41, 190: 42, 186: 43, 191: 44 }, 'janko': { } }; return function PianoKeyboard(kbdEl) { var // sharp suffix sharpSuffix = '#', // keyboard class kbdClass = 'piano-keyboard', // normalized keyboard data kbdData = {}; /** * Gets the octave of a key. * @param keyNumber The key's number, starting with 0 = G#0. * @returns {number} The octave */ function getOctave(keyNumber) { return Math.floor((keyNumber + pitchClassCount - 4) / pitchClassCount); } /** * Gets the pitch class of a key. * @param keyNumber The key's number, starting with 0 = G#0. * @returns {number} The pitch class (0 = G#, 11 = G). */ function getPitchClass(keyNumber) { while (keyNumber < 0) { keyNumber += pitchClassCount; } return keyNumber % pitchClassCount; } /** * Gets the left position ratio of a key. * @param keyNumber The key's number, starting with 0 = G#0. * @returns {number} The left position ratio, with 1 = spanning a full octave. */ function getLeftPositionRatio(keyNumber) { var ratios = [ (8 / pitchClassCount) + blackKeyRatioAdjustment / 2, (5 / whiteKeyPitchClassCount), (10 / pitchClassCount), (6 / whiteKeyPitchClassCount), 0, (1 / pitchClassCount), (1 / whiteKeyPitchClassCount), (3 / pitchClassCount) + blackKeyRatioAdjustment, (2 / whiteKeyPitchClassCount), (3 / whiteKeyPitchClassCount), (6 / pitchClassCount) + blackKeyRatioAdjustment, (4 / whiteKeyPitchClassCount) ]; return ratios[getPitchClass(keyNumber)]; } /** * Gets the name of the key's pitch class. * @param keyNumber The key's number, starting with 0 = G#0. * @returns {string} The name of the pitch class. */ function getPitchClassName(keyNumber) { var pitchClasses = [ 'G' + sharpSuffix, 'A', 'A' + sharpSuffix, 'B', 'C', 'C' + sharpSuffix, 'D', 'D' + sharpSuffix, 'E', 'F', 'F' + sharpSuffix, 'G' ]; return pitchClasses[getPitchClass(keyNumber)]; } /** * Gets the width unit of the keyboard. * @returns {string} The width unit. */ function getWidthUnit() { var whiteKeyWidth = parseFloat(kbdData.whiteKeyWidth); if (isNaN(whiteKeyWidth)) { switch (kbdData.whiteKeyWidth) { case 'auto': return '%'; default: break; } } return 'px'; } /** * Gets the unit-less black key width of the keyboard. * @returns {number} The unit-less width of the black keys. */ function getBlackKeyWidth() { var blackKeyWidth = getWhiteKeyWidth() * whiteKeyPitchClassCount / pitchClassCount; if (kbdData.keyProportion === 'balanced') { return getWhiteKeyWidth(); } return getWidthUnit() === 'px' ? Math.ceil(blackKeyWidth) : blackKeyWidth; } /** * Gets the unit-less white key width of the keyboard. * @returns {number} The unit-less width of the white keys. */ function getWhiteKeyWidth() { var whiteKeyWidth = parseFloat(kbdData.whiteKeyWidth), startKey = kbdData.startKey, endKey = kbdData.endKey; if (isNaN(whiteKeyWidth)) { switch (kbdData.whiteKeyWidth) { case 'auto': if (kbdData.keyProportion === 'balanced') { return 100 / (kbdData.endKey - kbdData.startKey + 1); } return 100 / getWhiteKeysInRange(startKey, endKey); default: break; } } return whiteKeyWidth; } /** * Gets the unit-less horizontal offset of the keyboard. * @returns {number} The horizontal offset of the keyboard. */ function getHorizontalOffset() { var whiteKeyWidth = getWhiteKeyWidth(), ratio = getLeftPositionRatio(kbdData.startKey), octave = getOctave(kbdData.startKey); if (kbdData.keyProportion === 'balanced') { return (kbdData.startKey - 1) * whiteKeyWidth; } return (whiteKeyWidth * whiteKeyPitchClassCount * ratio) + (octave * whiteKeyWidth * whiteKeyPitchClassCount); } /** * Gets the white keys within a range of keys. * @param startKey The start key number of the range. * @param endKey The end key number of the range. * @returns {number} The number of white keys within the range. */ function getWhiteKeysInRange(startKey, endKey) { var whiteKeys = 0, i; for (i = startKey; i <= endKey; i++) { (function (i) { switch (getPitchClass(i)) { case PitchClass.GSharp: case PitchClass.ASharp: case PitchClass.CSharp: case PitchClass.DSharp: case PitchClass.FSharp: return; default: break; } ++whiteKeys; })(i); } return whiteKeys; } /** * Gets the left offset of the key. * @param keyNumber * @returns {number} The left offset of the key. */ function getLeftOffset(keyNumber) { var whiteKeyWidth = getWhiteKeyWidth(), ratio = getLeftPositionRatio(keyNumber), octave = getOctave(keyNumber); if (kbdData.keyProportion === 'balanced') { switch (getPitchClass(keyNumber)) { case PitchClass.A: case PitchClass.B: case PitchClass.D: case PitchClass.E: case PitchClass.G: return ((keyNumber - 1) * whiteKeyWidth - whiteKeyWidth / 2) - getHorizontalOffset(); default: break; } return ((keyNumber - 1) * whiteKeyWidth) - getHorizontalOffset(); } return (whiteKeyWidth * whiteKeyPitchClassCount * ratio + (octave * whiteKeyWidth * whiteKeyPitchClassCount)) - getHorizontalOffset(); } /** * Generates styles for white keys. * @returns {string} The CSS string for styling white keys. */ function generateStyleForWhiteKeys() { var css = '', whiteKeyWidth = getWhiteKeyWidth(), widthUnit = getWidthUnit(), i; for (i = kbdData.startKey; i <= kbdData.endKey; i++) { (function (i) { var left; left = getLeftOffset(i); css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.key[data-key="' + i + '"]{left:' + left + widthUnit + '}'; })(i); } css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key{width:' + whiteKeyWidth + widthUnit + '}'; if (widthUnit !== '%') { if (kbdData.keyProportion === 'balanced') { css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]{width:' + ((kbdData.endKey - kbdData.startKey + 1) * whiteKeyWidth) + widthUnit + '}' } else { css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]{width:' + (getWhiteKeysInRange(kbdData.startKey, kbdData.endKey) * whiteKeyWidth) + widthUnit + '}' } } if (kbdData.keyProportion === 'balanced') { css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="C"],.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="E"],.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="F"],.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="B"]{width:' + (whiteKeyWidth * 1.5) + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="D"],.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="G"],.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="A"]{width:' + (whiteKeyWidth * 2) + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="C"]:last-child{width:' + whiteKeyWidth + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="D"]:last-child{width:' + (whiteKeyWidth * 1.5) + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="F"]:last-child{width:' + whiteKeyWidth + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="G"]:last-child{width:' + (whiteKeyWidth * 1.5) + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="A"]:last-child{width:' + (whiteKeyWidth * 1.5) + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key:first-child{left:0}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="D"]:first-child{width:' + (whiteKeyWidth * 1.5) + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="E"]:first-child{width:' + whiteKeyWidth + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="G"]:first-child{width:' + (whiteKeyWidth * 1.5) + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="A"]:first-child{width:' + (whiteKeyWidth * 1.5) + widthUnit + '}'; css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.white.key[data-pitch="B"]:first-child{width:' + whiteKeyWidth + widthUnit + '}'; } return css; } /** * Generates styles for black keys. * @returns {string} CSS string for styling black keys. */ function generateStyleForBlackKeys() { var css = '', blackKeyWidth = getBlackKeyWidth(), widthUnit = getWidthUnit(); css += '.piano-keyboard[data-kbd-id="' + kbdData.id + '"]>.black.key{width:' + blackKeyWidth + widthUnit + '}'; return css; } /** * Generates styles for the keyboard. * @returns {string} CSS string for styling the keyboard. */ function generateStyle() { var css = ''; css += generateStyleForWhiteKeys(); css += generateStyleForBlackKeys(); return css; } /** * Generates the keys of the keyboard. */ function generateKeys() { var i; for (i = kbdData.startKey; i <= kbdData.endKey; i++) { (function (i) { var key = document.createElement('button'), pitchClass = getPitchClassName(i), octave = getOctave(i); key.dataset.key = i; key.dataset.octave = octave; key.classList.add(pitchClass.indexOf(sharpSuffix) > -1 ? 'black' : 'white'); key.dataset.pitch = pitchClass; key.classList.add('key'); key.setAttribute('tabindex', -1); kbdEl.appendChild(key); })(i); } } /** * Paints the key being pressed. * @param {Element} keyEl The key element. */ function paintNoteOn(keyEl) { keyEl.classList.add('-active'); } /** * Paints the key being released. * @param {Element} keyEl The key element. */ function paintNoteOff(keyEl) { keyEl.classList.remove('-active'); } /** * Triggers a keyboard event. * @param {string} type The event type. * @param {string} detail Additional data of the event. * @returns {CustomEvent} The event. */ function triggerKeyboardEvent(type, detail) { var event = new CustomEvent(type); event.eventName = type; switch (type) { case 'noteon': case 'noteoff': event.key = parseInt(detail.key.dataset.key); event.velocity = parseInt(detail.velocity); event.octave = parseInt(detail.key.dataset.octave); event.pitch = detail.key.dataset.pitch; break; default: break; } kbdEl.dispatchEvent(event); return event; } /** * Binds the events of the keyboard. */ function bindEvents() { var mouseVel = 0; /** * * @param e */ function onNoteOn(e) { var kbdEvent; e.preventDefault(); if (e.buttons === 1 && !e.target.classList.contains('-active') && e.target.classList.contains('key')) { if (kbdEl.tabIndex === 0) { kbdEl.focus(); } kbdEvent = triggerKeyboardEvent('noteon', { key: e.target, velocity: e.velocity }); if (kbdEvent.defaultPrevented) { return; } paintNoteOn(e.target); } } /** * * @param e */ function onNoteOff(e) { var kbdEvent; e.preventDefault(); if (e.target.classList.contains('-active') && e.target.classList.contains('key')) { kbdEvent = triggerKeyboardEvent('noteoff', { key: e.target, velocity: e.velocity }); if (kbdEvent.defaultPrevented) { return; } paintNoteOff(e.target); } } /** * * @param e */ function onKeyboardKeydown(e) { var bindingsMap, key, keyEl, kbdEvent; if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) { return; } bindingsMap = kbdData.bindingsMap.toLowerCase(); if (typeof bindingsMap === 'undefined') { return; } key = bindings[bindingsMap][e.which]; if (typeof key === 'undefined') { return; } keyEl = kbdEl.querySelector('[data-key="' + key + '"]'); if (!keyEl || keyEl.classList.contains('-active')) { return; } kbdEvent = triggerKeyboardEvent('noteon', { key: keyEl, velocity: kbdData.keyboardVelocity }); if (kbdEvent.defaultPrevented) { return; } e.preventDefault(); paintNoteOn(keyEl); } /** * * @param e */ function onKeyboardKeyup(e) { var bindingsMap, key, keyEl, kbdEvent; if (e.shiftKey || e.ctrlKey || e.altKey || e.metaKey) { return; } bindingsMap = kbdData.bindingsMap.toLowerCase(); if (typeof bindingsMap === 'undefined') { return; } key = bindings[bindingsMap][e.which]; if (typeof key === 'undefined') { return; } keyEl = kbdEl.querySelector('[data-key="' + key + '"]'); if (!keyEl || !keyEl.classList.contains('-active')) { return; } kbdEvent = triggerKeyboardEvent('noteoff', { key: keyEl, velocity: kbdData.keyboardVelocity }); if (kbdEvent.defaultPrevented) { return; } e.preventDefault(); paintNoteOff(keyEl); } /** * * @param e */ function onMouseDown(e) { var maxY = parseFloat(window.getComputedStyle(e.target).height), offsetY = e.offsetY + parseFloat(getComputedStyle(e.target).borderTopWidth); mouseVel = Math.floor(maxVelocity * (offsetY / maxY)); e.velocity = mouseVel; onNoteOn(e); } /** * * @param e */ function onMouseUp(e) { e.velocity = mouseVel; onNoteOff(e); } /** * * @param e */ function onMouseEnter(e) { e.velocity = mouseVel; onNoteOn(e); } /** * * @param e */ function onMouseLeave(e) { e.velocity = mouseVel; onNoteOff(e); } kbdEl.addEventListener('keydown', onKeyboardKeydown); kbdEl.addEventListener('keyup', onKeyboardKeyup); kbdEl.addEventListener('mousedown', onMouseDown, true); kbdEl.addEventListener('mouseenter', onMouseEnter, true); kbdEl.addEventListener('mouseup', onMouseUp, true); kbdEl.addEventListener('mouseleave', onMouseLeave, true); } /** * Normalizes the keyboard data. */ function normalizeKeyboardData() { var temp; kbdData.id = (kbdEl.dataset.kbdId = kbdEl.dataset.kbdId || Date.now()); kbdData.startKey = parseInt(kbdEl.dataset.startKey) || grandPianoStartKey; kbdData.endKey = parseInt(kbdEl.dataset.endKey) || grandPianoEndKey; kbdData.whiteKeyWidth = kbdEl.dataset.whiteKeyWidth || 'auto'; kbdData.keyboardVelocity = parseInt(kbdEl.dataset.keyboardVelocity) || keyboardVelocity; kbdData.keyProportion = kbdEl.dataset.keyProportion; if (isNaN(kbdData.startKey)) { kbdData.startKey = grandPianoStartKey; } if (isNaN(kbdData.endKey)) { kbdData.endKey = grandPianoEndKey; } if (kbdData.startKey > kbdData.endKey) { temp = kbdData.startKey; kbdData.startKey = kbdData.endKey; kbdData.endKey = temp; } kbdData.bindingsMap = 'standard'; // TODO implement other bindings maps } /** * */ function addKeyboardUiAttributes() { if (kbdEl.hasAttribute('tabindex')) { return; } kbdEl.setAttribute('tabindex', 0); } /** * */ function initializeKeyboardStyle() { var styleEl = document.querySelector('style[data-kbd-id="' + kbdData.id + '"]'), styleParent = document.getElementsByTagName('head')[0] || document.body; if (!styleEl) { styleEl = document.createElement('style'); styleEl.dataset.kbdId = kbdData.id; styleParent.appendChild(styleEl); } styleEl.innerHTML = generateStyle(kbdData); if (kbdEl.classList.contains(kbdClass)) { return; } kbdEl.classList.add(kbdClass); } /** * */ function addMethods() { kbdEl.playNote = function playNote(noteInfo) { var keyEl = kbdEl.querySelector('[data-key="' + noteInfo.key + '"]'), detail; if (!keyEl || keyEl.classList.contains('-active')) { return kbdEl; } detail = { key: keyEl }; detail.velocity = noteInfo.velocity; detail.pan = noteInfo.pan; triggerKeyboardEvent('noteon', detail); paintNoteOn(keyEl); return kbdEl; }; kbdEl.stopNote = function stopNote(noteInfo) { var keyEl = kbdEl.querySelector('[data-key="' + noteInfo.key + '"]'), detail; if (!keyEl || keyEl.classList.contains('-active')) { return kbdEl; } detail = { key: keyEl }; detail.velocity = noteInfo.velocity; detail.pan = noteInfo.pan; triggerKeyboardEvent('noteoff', detail); paintNoteOn(keyEl); return kbdEl; }; } normalizeKeyboardData(); initializeKeyboardStyle(); generateKeys(); bindEvents(); addKeyboardUiAttributes(); addMethods(); }; });