import Widget from "ui/core/widget";
import Constraint from "util/constraint";
import ConstraintSpec from "util/constraint-def";
/**
* Class representing an piano keyboard widget
*
* @class
* @implements {Widget}
*/
class Keyboard extends Widget {
/**
* @constructor
* @param {object} container - DOM container for the widget.
* @param {object} [o] - Options.
* @param {number} [o.bottomNote=48] - The bottom note (MIDI pitch) of the keyboard.
* @param {number} [o.topNote=71] - The top note (MIDI pitch) of the keyboard.
* @param {string} [o.keyBorderColor="#484848"] - The color of the border separating the keys.
* @param {string} [o.blackKeyColor="#484848"] - The color used for the black keys.
* @param {string} [o.whiteKeyColor="#fff"] - The color used for the white keys.
* @param {string} [o.blackKeyActiveColor="#888"] - The color used to represent an active black key.
* @param {string} [o.whiteKeyActiveColor="#333"] - The color used to represent an active white key.
* @param {number} [o.blackKeyHeightAspect=0.6] - The aspect ratio of black key height to white key height.
* @param {number} [o.blackKeyWidthAspect=0.66] - The aspect ratio of black key width to white key width.
* @param {string} [o.orientation="horizontal"] - The keyboard orientation. sible values are 'monophonic'
* (only one active note at a time), or 'polyphonic'
* (can have several active notes at a time).
* @param {boolean} [o.isEditable=true] - Boolean specifying whether the keyboard
* is editable by the mouse or touch interactions.
* A non-editable keyboard may be used as a visual
* diagram, for example.
* @param {number | string} [o.maxPolyphony="no max"] - The maximum number of keys that can be active at the
* same time. Values can be a number, or "no max".
*/
constructor(container, o) {
super(container, o);
return this;
}
/* ==============================================================================================
* PUBLIC API
*/
/**
* Sets the options.
* @public
* @override
* @param {object} [o] - Options to set. See {@link Keyboard#constructor} for list of options.
*/
setOptions(o) {
o = o || {};
// ensure that the bottom note is a white key (a black key cannot be at the edge when drawing the keyboard)
if (o.bottomNote !== undefined && !this._isWhiteKey(o.bottomNote)) {
--o.bottomNote;
}
// ensure that the bottom note is a white key (a black key cannot be at the edge when drawing the keyboard)
if (o.topNote !== undefined && !this._isWhiteKey(o.topNote)) {
++o.topNote;
}
super.setOptions(o);
}
/**
* Returns the last note event.
* @public
* @override
* @returns {object} - An object representing the last note event that occured as {pitch, vel}
*/
getVal() {
return Object.assign({}, this.lastNoteEvent);
}
/**
* Returns the currently active notes.
* @public
* @override
* @returns {array} - An array of active notes. Each element is a [pitch, vel] pair.
*/
getActiveNotes() {
return this.getState().activeNotes.map(note => [ note.pitch, note.vel ]);
}
/**
* Sets the current keyboard state using an array of {pitch, val} objects.
* Same as setVal(), but will not cause an observer callback trigger.
* @public
* @override
* @param {array} newNote - New value (array representing active notes with each entry in the form {pitch, val}).
* @param {boolean} isVelToggled - A boolean indicating whether a non-zero vel of the same
* pitch will turn a note off if it is turned on.
*/
setInternalVal(newNote, isVelToggled) {
let newState = this._getNewStateFromNewNote(newNote, isVelToggled);
this.setInternalState(newState);
}
/**
* Sets the current keyboard state using an array of {pitch, val} objects.
* Same as setInternalVal(), but will cause an observer callback trigger.
* @public
* @param {array} newVal - New value (array representing active notes with each entry in the form {pitch, val}).
* @param {boolean} isVelToggled - A boolean indicating whether a non-zero vel of the same
* pitch will turn a note off if it is turned on.
*/
setVal(newNote, isVelToggled) {
let newState = this._getNewStateFromNewNote(newNote, isVelToggled);
this.setState(newState);
}
/* ==============================================================================================
* INITIALIZATION METHODS
*/
/**
* Initialize the options
* @override
* @private
*/
_initOptions(o) {
// set the defaults
this.o = {
bottomNote: 48,
topNote: 71,
keyBorderColor: "#484848",
blackKeyColor: "#484848",
whiteKeyColor: "#fff",
blackKeyActiveColor: "#999",
whiteKeyActiveColor: "#999",
blackKeyHeightAspect: 0.6,
blackKeyWidthAspect: 0.66,
maxPolyphony: "no max",
orientation: "horizontal",
isEditable: true,
mouseSensitivity: 1.2
};
// override defaults with provided options
super._initOptions(o);
}
/**
* Initialize state constraints
* @override
* @private
*/
_initStateConstraints() {
const _this = this;
this.stateConstraints = new ConstraintSpec({
activeNotes: [{
pitch: new Constraint({ min: 0, max: 127 }),
vel: new Constraint({ min: 0, max: 127})
}]
});
}
/**
* Initializes the state.
* State is represented as an array of active notes, each of which is an object
* { pitch, vel }, where pitch is MIDI pitch (0 - 127) and vel is MIDI velocity
* (0 - 127). A vel of 0 is reported once for each note-off event, and not
* reported on subsequent callback notifications.
* @override
* @private
*/
_initState() {
this.state = {
activeNotes: []
};
// Object representing the last note event that occured.
this.lastNoteEvent = {};
}
/**
* Initialize the svg elements
* @override
* @private
*/
_initSvgEls() {
const _this = this;
this.svgEls = {
keys: []
};
this._update();
}
/**
* Updates the SVG elements.
* Adds or removes a number of SVG elements to match the current number of keys.
*/
_updateSvgEls() {
let numKeys = this._getNumKeys();
// add SVG elements representing keys to match current number of keys
for (let i = this.svgEls.keys.length; i < numKeys; ++i) {
this._addSvgKey();
}
// remove SVG elements representing keys to match current number of keys
for (let i = this.svgEls.keys.length; i > numKeys; ++i) {
this._removeSvgKey();
}
}
/**
* Initializes mouse and touch event handlers.
* @override
* @private
*/
_initHandlers() {
const _this = this;
this.handlers = {
touch: function touch(ev) {
ev.preventDefault();
let touchVel = Math.ceil(127 * (_this._getKeyboardHeight() - _this._getRelativeY(ev.clientY)) / _this._getKeyboardHeight());
_this._touchKey(ev.target, touchVel);
for (let i = 0; i < _this.svgEls.keys.length; ++i) {
// activate / toggle a key on mouse enter
_this.svgEls.keys[i].addEventListener("mouseenter", _this.handlers.touch);
_this.svgEls.keys[i].addEventListener("touchenter", _this.handlers.touch);
_this.svgEls.keys[i].addEventListener("mouseup", _this.handlers.release);
_this.svgEls.keys[i].addEventListener("touchend", _this.handlers.release);
}
},
release: function release() {
for (let i = 0; i < _this.svgEls.keys.length; ++i) {
_this.svgEls.keys[i].removeEventListener("mouseenter", _this.handlers.touch);
_this.svgEls.keys[i].removeEventListener("touchenter", _this.handlers.touch);
}
}
};
for (let i = 0; i < this.svgEls.keys.length; ++i) {
this.svgEls.keys[i].addEventListener("mousedown", this.handlers.touch);
this.svgEls.keys[i].addEventListener("touchdown", this.handlers.touch);
}
}
/**
*
*/
_touchKey(targetKey, vel) {
const _this = this;
let keyIdx = this.svgEls.keys.findIndex(key => key === targetKey);
let newNote = {
pitch: keyIdx + _this.o.bottomNote,
vel: vel
};
this.setVal(newNote, true);
}
/**
* Updates (redraws) component based on state.
* @override
* @private
*/
_update() {
var x, y, width, height, fill, stroke;
let blackKeys = [];
// an array of velocities representing all possible notes (vel 0 means note is off)
let notes = new Array(this._getNumKeys());
notes.fill(0);
// put value of 1 for all active notes in the note array
this.getState().activeNotes.forEach(activeNote => {
notes[activeNote.pitch - this.getOptions().bottomNote] = 1;
});
this._updateSvgEls();
for (let keyIdx = 0, whiteKeyIdx = 0; keyIdx < this.svgEls.keys.length; ++keyIdx) {
let pitch = this._getPitchForKeyIdx(keyIdx);
let attr = {};
if (this._isWhiteKey(pitch)) {
attr.x = this._getWhiteKeyWidth() * whiteKeyIdx;
attr.y = 0;
attr.width = this._getWhiteKeyWidth();
attr.height = this._getKeyboardHeight();
attr.fill = (notes[keyIdx] === 0) ?
this.getOptions().whiteKeyColor
: this.getOptions().whiteKeyActiveColor;
attr.stroke = this.getOptions().keyBorderColor;
++whiteKeyIdx;
} else {
blackKeys.push(this.svgEls.keys[keyIdx]);
// black keys are offset by 2/3 of white key width, and are 2/3 width and height of black keys
attr.x = (this._getWhiteKeyWidth() * whiteKeyIdx) - ( this.getOptions().blackKeyWidthAspect * this._getWhiteKeyWidth() / 2 );
attr.y = 0;
attr.width = this.getOptions().blackKeyWidthAspect * this._getWhiteKeyWidth();
attr.height = this.getOptions().blackKeyHeightAspect * this._getKeyboardHeight();
attr.fill = (notes[keyIdx] === 0) ?
this.getOptions().blackKeyColor
: this.getOptions().blackKeyActiveColor;
attr.stroke = this.getOptions().keyBorderColor;
}
this._setKeyAttributes(keyIdx, attr);
}
// remove and reappend black keys so they are on top of the white keys
for (let i = 0; i < blackKeys.length; ++i) {
this.svg.removeChild(blackKeys[i]);
this.svg.appendChild(blackKeys[i]);
}
}
/* ===========================================================================
* INTERNAL FUNCTIONALITY
*/
/**
* Returns a newState object representing a new keyboard state based on a new note provided.
* @param {object} newNote - A note object of format { pitch: number, vel: number }.
* @param {number} newNote.pitch
* @param {number} newNote.vel
* @param {boolean} isVelToggled - A boolean indicating whether a non-zero vel of the same
* pitch will turn a note off if it is turned on.
* @returns {object} An object representing the new state.
*/
_getNewStateFromNewNote(newNote, isVelToggled) {
let newState = this.getState();
let noteIdx = newState.activeNotes.findIndex(note => note.pitch === newNote.pitch);
if (noteIdx === -1) {
if (newNote.vel > 0) {
if (this.o.maxPolyphony === "no max" || (newState.activeNotes.length < this.o.maxPolyphony)) {
newState.activeNotes.push(newNote);
} else {
newState.activeNotes.splice(0, 1);
newState.activeNotes.push(newNote);
}
}
} else {
if (newNote.vel <= 0 || isVelToggled) {
newState.activeNotes.splice(noteIdx, 1);
newNote.vel = 0;
} else {
newState.activeNotes[noteIdx].vel = newNote.vel;
}
}
this.lastNoteEvent = newNote;
return newState;
}
/**
* Adds an SVG element representing a key.
*/
_addSvgKey() {
let newKey = document.createElementNS(this.SVG_NS, "rect");
this.svg.appendChild(newKey);
this.svgEls.keys.push(newKey);
newKey.addEventListener("mousedown", this.handlers.touch);
newKey.addEventListener("touchdown", this.handlers.touch);
}
/**
* Removes an SVG element representing a key.
*/
_removeSvgKey() {
let key = this.svgEls.keys[this.svgEls.keys.length - 1];
this.svg.removeChild(key);
key = null;
this.svgEls.keys.pop();
}
/* ===========================================================================
* HELPER METHODS
*/
/**
* Sets attributes for an SVG rectangle representing a key with the given index.
*/
_setKeyAttributes(keyIdx, attr) {
this.svgEls.keys[keyIdx].setAttribute("x", attr.x);
this.svgEls.keys[keyIdx].setAttribute("y", attr.y);
this.svgEls.keys[keyIdx].setAttribute("width", attr.width);
this.svgEls.keys[keyIdx].setAttribute("height", attr.height);
this.svgEls.keys[keyIdx].setAttribute("fill", attr.fill);
this.svgEls.keys[keyIdx].setAttribute("stroke", attr.stroke);
}
/**
* Returns the width of the keyboard, taking orientation into account.
* If orientation is horizontal, width of the keyboard would equal
* width of the canvas. If orientation is vertical, width of the
* keyboard would equal the height of the canvas.
* @private
* @throws {Error} if o.orientation is not one of the allowed values.
*/
_getKeyboardWidth() {
let orientation = this.getOptions().orientation;
if (orientation === "horizontal" || orientation === "horizontal-mirrored") {
return this._getWidth();
} else if (orientation === "vertical" || orientation === "vertical-mirrored") {
return this._getHeight();
}
}
/**
* Returns the height of the keyboard, taking orientation into account.
* If orientation is horizontal, height of the keyboard would equal
* height of the canvas. If orientation is vertical, height of the
* keyboard would equal the width of the canvas.
* @private
* @throws {Error} if o.orientation is not one of the allowed values.
*/
_getKeyboardHeight() {
let orientation = this.getOptions().orientation;
if (orientation === "horizontal" || orientation === "horizontal-mirrored") {
return this._getHeight();
} else if (orientation === "vertical" || orientation === "vertical-mirrored") {
return this._getWidth();
}
}
/**
* Returns the MIDI note number for the given key number.
* @private
* @param {number} keyIdx - The index of the key to be queried.
* @returns {number} - MIDI note number for the given key number
*/
_getPitchForKeyIdx(keyIdx) {
return this.getOptions().bottomNote + keyIdx;
}
/**
* Returns the total number of keys on the keyboard.
* @private
* @returns {number} - Total number of keys.
*/
_getNumKeys() {
return (this.o.topNote - this.o.bottomNote) + 1;
}
/**
* Returns the number of white keys on the keyboard.
* @private
* @returns {number} - Number of white keys.
*/
_getNumWhiteKeys() {
let whiteKeyCount = 0;
for (let curNote = this.getOptions().bottomNote; curNote <= this.getOptions().topNote; ++curNote) {
if (this._isWhiteKey(curNote)) {
++whiteKeyCount;
}
}
return whiteKeyCount;
}
/**
* Returns the width of each white key in px.
* @private
* @returns {number} - Width of each white key in px.
*/
_getWhiteKeyWidth() {
return this._getKeyboardWidth() / this._getNumWhiteKeys();
}
/**
* Returns true if the given MIDI note number is a white key on the piano.
* @private
* @param {number} note - The MIDI note number for the given note.
* @returns {boolean} - True if the note is a white key, false if not.
*/
_isWhiteKey(note) {
if (note % 12 === 0 ||
note % 12 === 2 ||
note % 12 === 4 ||
note % 12 === 5 ||
note % 12 === 7 ||
note % 12 === 9 ||
note % 12 === 11) {
return true;
} else {
return false;
}
}
}
export default Keyboard;