Source: music21/keyboard.js

/**
 * music21j -- Javascript reimplementation of Core music21p features.
 * music21/keyboard -- PianoKeyboard rendering and display objects
 *
 * Copyright (c) 2013-18, Michael Scott Cuthbert and cuthbertLab
 * Based on music21 (=music21p), Copyright (c) 2006–17, Michael Scott Cuthbert and cuthbertLab
 *
 * Keyboard module, see {@link music21.keyboard} namespace
 *
 * @exports music21/keyboard
 *
 * keyboard namespace -- tools for creating an onscreen keyboard and interacting with it.
 *
 * @namespace music21.keyboard
 * @memberof music21
 * @requires music21/pitch
 * @requires music21/common
 * @requires music21/miditools
 * @requires jquery
 * @requires MIDI
 *
 * @example Minimum usage:
 * const kd = document.getElementById('keyboardDiv');
 * const k = new music21.keyboard.Keyboard();
 * k.appendKeyboard(kd, 6, 57); // 88 key keyboard
 *
 * // configurable:
 * k.scaleFactor = 1.2;  // default 1
 * k.whiteKeyWidth = 40; // default 23
 *
 */
import * as $ from 'jquery';
import * as MIDI from 'midicube';

import * as common from './common.js';
import * as miditools from './miditools.js';
import * as pitch from './pitch.js';

/**
 * Represents a single Key
 *
 * @class Key
 * @memberof music21.keyboard
 * @property {Array<function>} callbacks - called when key is clicked/selected
 * @property {number} [scaleFactor=1]
 * @property {music21.keyboard.Keyboard|undefined} parent
 * @property {int} id - midi number associated with the key.
 * @property {music21.pitch.Pitch|undefined} pitchObj
 * @property {SVGElement|undefined} svgObj - SVG representing the drawing of the key
 * @property {SVGElement|undefined} noteNameSvgObj - SVG representing the note name drawn on the key
 * @property {string} keyStyle='' - css style information for the key
 * @property {string} keyClass='' - css class information for the key ("keyboardkey" + this is the class)
 * @property {number} width - width of key
 * @property {number} height - height of key
 */
export class Key {
    constructor() {
        this.classes = ['Key']; // name conflict with key.Key
        this.callbacks = [];
        this.scaleFactor = 1;
        this.parent = undefined;
        this.id = 0;
        this.width = 23;
        this.height = 120;
        this.pitchObj = undefined;
        this.svgObj = undefined;
        this.noteNameSvgObj = undefined;
        this.keyStyle = '';
        this.keyClass = '';
    }

    /**
     * Gets an SVG object for the key
     *
     * @param {number} startX - X position in pixels from left of keyboard to draw key at
     * @returns {SVGElement} a SVG rectangle for the key
     */
    makeKey(startX) {
        const keyAttrs = {
            style: this.keyStyle,
            x: startX,
            y: 0,
            class: 'keyboardkey ' + this.keyClass,
            id: this.id,
            width: this.width * this.scaleFactor,
            height: this.height * this.scaleFactor,
            rx: 3,
            ry: 3,
        };
        const keyDOM = common.makeSVGright('rect', keyAttrs);
        for (const x in this.callbacks) {
            if ({}.hasOwnProperty.call(this.callbacks, x)) {
                keyDOM.addEventListener(x, this.callbacks[x], false);
            }
        }
        this.svgObj = keyDOM;
        return keyDOM;
    }

    /**
     * Adds a circle (red) on the key (to mark middle C etc.)
     *
     * @param {string} [strokeColor='red']
     * @returns {SVGElement}
     */
    addCircle(strokeColor) {
        if (
            this.svgObj === undefined
            || this.parent === undefined
            || this.parent.svgObj === undefined
        ) {
            return undefined;
        }
        if (strokeColor === undefined) {
            strokeColor = 'red';
        }
        const x = parseInt(this.svgObj.getAttribute('x'));
        const cx = x + this.parent.scaleFactor * this.width / 2;
        // console.log('cx', cx);
        const keyattrs = {
            stroke: strokeColor,
            'stroke-width': 3,
            fill: 'none',
            cx,
            cy: (this.height - 10) * this.parent.scaleFactor,
            class: 'keyboardkeyannotation',
            r: this.width * this.parent.scaleFactor / 4,
        };

        const circleDom = common.makeSVGright('circle', keyattrs);
        this.parent.svgObj.appendChild(circleDom);
        // console.log(circleDom);
        return circleDom;
    }

    /**
     * Adds the note name on the key
     *
     * @param {Boolean} [labelOctaves=false] - use octave numbers too?
     * @returns {this}
     */
    addNoteName(labelOctaves) {
        if (
            this.svgObj === undefined
            || this.parent === undefined
            || this.parent.svgObj === undefined
        ) {
            return this;
        }
        if (this.id === 0 && this.pitchObj === undefined) {
            return this;
        } else if (this.pitchObj === undefined) {
            this.pitchObj = new pitch.Pitch();
            this.pitchObj.ps = this.id;
        }
        if (
            this.pitchObj.accidental !== undefined
            && this.pitchObj.accidental.alter !== 0
        ) {
            return this;
        }
        let x = parseInt(this.svgObj.getAttribute('x'));
        let idStr = this.pitchObj.name;
        let fontSize = 14;
        if (labelOctaves === true) {
            idStr = this.pitchObj.nameWithOctave;
            fontSize = 12;
            x -= 2;
        }
        fontSize = Math.floor(fontSize * this.parent.scaleFactor);

        let textfill = 'white';
        if (this.keyClass === 'whitekey') {
            textfill = 'black';
        }
        const textattrs = {
            fill: textfill,
            x: x + this.parent.scaleFactor * (this.width / 2 - 5),
            y: this.parent.scaleFactor * (this.height - 20),
            class: 'keyboardkeyname',
            'font-size': fontSize,
        };

        const textDom = common.makeSVGright('text', textattrs);
        const textNode = document.createTextNode(idStr);
        textDom.appendChild(textNode);
        this.noteNameSvgObj = textDom; // store for removing...
        this.parent.svgObj.appendChild(textDom);
        return this;
    }

    /**
     * Removes the note name from the key (if exists)
     *
     * @returns {undefined}
     */
    removeNoteName() {
        if (
            this.svgObj === undefined
            || this.parent === undefined
            || this.parent.svgObj === undefined
        ) {
            return;
        }
        if (this.noteNameSvgObj === undefined) {
            return;
        }
        if (this.noteNameSvgObj.parentNode === this.parent.svgObj) {
            this.parent.svgObj.removeChild(this.noteNameSvgObj);
        }
        this.noteNameSvgObj = undefined;
    }
}

/**
 * Defaults for a WhiteKey (width, height, keyStyle, keyClass)
 *
 * @class WhiteKey
 * @memberof music21.keyboard
 * @extends music21.keyboard.Key
 */
export class WhiteKey extends Key {
    constructor() {
        super();
        this.width = 23;
        this.height = 120;
        this.keyStyle = 'fill:#fffff6;stroke:black';
        this.keyClass = 'whitekey';
    }
}

/**
 * Defaults for a BlackKey (width, height, keyStyle, keyClass)
 *
 * @class BlackKey
 * @memberof music21.keyboard
 * @extends music21.keyboard.Key
 */
export class BlackKey extends Key {
    constructor() {
        super();
        this.width = 13;
        this.height = 80;
        this.keyStyle = 'fill:black;stroke:black';
        this.keyClass = 'blackkey';
    }
}

/**
 * A Class representing a whole Keyboard full of keys.
 *
 * @class Keyboard
 * @memberof music21.keyboard
 * @property {number} whiteKeyWidth - default 23
 * @property {number} scaleFactor - default 1
 * @property {Object} keyObjects - a mapping of id to {@link music21.keyboard.Key} objects
 * @property {SVGElement} svgObj - the SVG object of the keyboard
 * @property {Boolean} markC - default true
 * @property {Boolean} showNames - default false
 * @property {Boolean} showOctaves - default false
 * @property {string} startPitch - default "C3"
 * @property {string} endPitch - default "C5"
 * @property {Boolean} hideable - default false -- add a way to hide and show keyboard
 * @property {Boolean} scrollable - default false -- add scroll bars to change octave
 */
export class Keyboard {
    constructor() {
        this.whiteKeyWidth = 23;
        this._defaultWhiteKeyWidth = 23;
        this._defaultBlackKeyWidth = 13;
        this.scaleFactor = 1;
        this.height = 120; // does nothing right now...
        this.keyObjects = {};
        this.svgObj = undefined;

        this.markC = true;
        this.showNames = false;
        this.showOctaves = false;

        this.startPitch = 'C3';
        this.endPitch = 'C5';
        this._startDNN = undefined;
        this._endDNN = undefined;

        this.hideable = false;
        this.scrollable = false;
        /**
         * An object of callbacks on events.
         *
         * default:
         *
         * - click: this.clickHandler
         *
         * @name callbacks
         * @type {Object}
         */
        this.callbacks = {
            click: (keyClicked) => this.clickHandler(keyClicked),
        };
        //   more accurate offsets from http://www.mathpages.com/home/kmath043.htm
        this.sharpOffsets = {
            0: 14.3333,
            1: 18.6666,
            3: 13.25,
            4: 16.25,
            5: 19.75,
        };
    }

    /**
     * Redraws the SVG associated with this Keyboard
     *
     * @returns {SVGElement} new svgDOM
     */
    redrawSVG() {
        if (this.svgObj === undefined) {
            return undefined;
        }
        const oldSVG = this.svgObj;
        const svgParent = oldSVG.parentNode;
        this.keyObjects = {};
        const svgDOM = this.createSVG();
        svgParent.replaceChild(svgDOM, oldSVG);
        return svgDOM;
    }

    /**
     * Appends a keyboard to the `where` parameter
     *
     * @param {jQuery|Node} [where]
     * @returns {music21.keyboard.Keyboard} this
     */
    appendKeyboard(where) {
        if (where === undefined) {
            where = document.body;
        } else { // noinspection JSUnresolvedVariable
            if (typeof where.jquery !== 'undefined') {
                where = where[0];
            }
        }

        let svgDOM = this.createSVG();

        if (this.scrollable) {
            svgDOM = this.wrapScrollable(svgDOM)[0];
        }

        if (this.hideable) {
            // make it so the keyboard can be shown or hidden...
            this.appendHideableKeyboard(where, svgDOM);
        } else {
            where.appendChild(svgDOM); // svg must use appendChild, not append.
        }
        return this;
    }

    /**
     * Handle a click on a given SVG object
     *
     * @param {SVGElement} keyRect - the dom object with the keyboard.
     */
    clickHandler(keyRect) {
        // to-do : integrate with jazzHighlight...
        const id = keyRect.id;
        const thisKeyObject = this.keyObjects[id];
        if (thisKeyObject === undefined) {
            return; // not on keyboard;
        }
        const storedStyle = thisKeyObject.keyStyle;
        let fillColor = '#c0c000';
        if (thisKeyObject.keyClass === 'whitekey') {
            fillColor = 'yellow';
        }
        keyRect.setAttribute('style', 'fill:' + fillColor + ';stroke:black');
        miditools.loadSoundfont('acoustic_grand_piano', i => {
            MIDI.noteOn(i.midiChannel, id, 100, 0);
            MIDI.noteOff(i.midiChannel, id, 500);
        });
        setTimeout(() => {
            keyRect.setAttribute('style', storedStyle);
        }, 500);
    }

    /**
     * Draws the SVG associated with this Keyboard
     *
     * @returns {SVGElement} new svgDOM
     */
    createSVG() {
        // DNN = pitch.diatonicNoteNum;
        // this._endDNN = final key note. I.e., the last note to be included, not the first note not included.
        // 6, 57 gives a standard 88-key keyboard;
        if (this._startDNN === undefined) {
            if (typeof this.startPitch === 'string') {
                const tempP = new pitch.Pitch(this.startPitch);
                this._startDNN = tempP.diatonicNoteNum;
            } else {
                this._startDNN = this.startPitch;
            }
        }

        if (this._endDNN === undefined) {
            if (typeof this.endPitch === 'string') {
                const tempP = new pitch.Pitch(this.endPitch);
                this._endDNN = tempP.diatonicNoteNum;
            } else {
                this._endDNN = this.endPitch;
            }
        }

        let currentIndex = (this._startDNN - 1) % 7; // C = 0
        const keyboardDiatonicLength = 1 + this._endDNN - this._startDNN;
        const totalWidth
            = this.whiteKeyWidth * this.scaleFactor * keyboardDiatonicLength;
        const height = 120 * this.scaleFactor;
        const heightString = height.toString() + 'px';

        const svgDOM = common.makeSVGright('svg', {
            'xml:space': 'preserve',
            height: heightString,
            width: totalWidth.toString() + 'px',
            class: 'keyboardSVG',
        });
        const movingPitch = new pitch.Pitch('C4');
        const blackKeys = [];
        const thisKeyboardObject = this;

        for (let wki = 0; wki < keyboardDiatonicLength; wki++) {
            movingPitch.diatonicNoteNum = this._startDNN + wki;
            const wk = new WhiteKey();
            wk.id = movingPitch.midi;
            wk.parent = this;
            this.keyObjects[movingPitch.midi] = wk;
            wk.scaleFactor = this.scaleFactor;
            wk.width = this.whiteKeyWidth;
            wk.callbacks.click = function whitekeyCallbacksClick() {
                thisKeyboardObject.callbacks.click(this);
            };

            const wkSVG = wk.makeKey(
                this.whiteKeyWidth * this.scaleFactor * wki
            );
            svgDOM.appendChild(wkSVG);

            if (
                (currentIndex === 0
                    || currentIndex === 1
                    || currentIndex === 3
                    || currentIndex === 4
                    || currentIndex === 5)
                && wki !== keyboardDiatonicLength - 1
            ) {
                // create but do not append BlackKey to the right of WhiteKey
                const bk = new BlackKey();
                bk.id = movingPitch.midi + 1;
                this.keyObjects[movingPitch.midi + 1] = bk;
                bk.parent = this;

                bk.scaleFactor = this.scaleFactor;
                bk.width
                    = this._defaultBlackKeyWidth
                    * this.whiteKeyWidth
                    / this._defaultWhiteKeyWidth;
                bk.callbacks.click = function blackKeyClicksCallback() {
                    thisKeyboardObject.callbacks.click(this);
                };

                let offsetFromWhiteKey = this.sharpOffsets[currentIndex];
                offsetFromWhiteKey
                    *= this.whiteKeyWidth
                    / this._defaultWhiteKeyWidth
                    * this.scaleFactor;
                const bkSVG = bk.makeKey(
                    this.whiteKeyWidth * this.scaleFactor * wki
                        + offsetFromWhiteKey
                );
                blackKeys.push(bkSVG);
            }
            currentIndex += 1;
            currentIndex %= 7;
        }
        // append blackkeys later since they overlap white keys;
        for (let bki = 0; bki < blackKeys.length; bki++) {
            svgDOM.appendChild(blackKeys[bki]);
        }

        this.svgObj = svgDOM;
        if (this.markC) {
            this.markMiddleC();
        }
        if (this.showNames) {
            this.markNoteNames(this.showOctaves);
        }

        return svgDOM;
    }

    /**
     * Puts a circle on middle c.
     *
     * @param {string} [strokeColor='red']
     */
    markMiddleC(strokeColor) {
        const midC = this.keyObjects[60];
        if (midC !== undefined) {
            midC.addCircle('red');
        }
    }

    /**
     * Puts note names on every white key.
     *
     * @param {Boolean} [labelOctaves=false]
     */
    markNoteNames(labelOctaves) {
        this.removeNoteNames(); // in case...
        for (const midi in this.keyObjects) {
            if ({}.hasOwnProperty.call(this.keyObjects, midi)) {
                const keyObj = this.keyObjects[midi];
                keyObj.addNoteName(labelOctaves);
            }
        }
    }

    /**
     * Remove note names on the keys, if they exist
     *
     * @returns {this}
     */
    removeNoteNames() {
        for (const midi in this.keyObjects) {
            if ({}.hasOwnProperty.call(this.keyObjects, midi)) {
                const keyObj = this.keyObjects[midi];
                keyObj.removeNoteName();
            }
        }
        return this;
    }

    /**
     * Wraps the SVG object inside a scrollable set of buttons
     *
     * Do not call this directly, just use createSVG after changing the
     * scrollable property on the keyboard to True.
     *
     * @param {SVGElement} svgDOM
     * @returns {jQuery}
     */
    wrapScrollable(svgDOM) {
        const $wrapper = $(
            "<div class='keyboardScrollableWrapper'></div>"
        ).css({
            display: 'inline-block',
        });
        const $bDown = $("<button class='keyboardOctaveDown'>&lt;&lt;</button>")
            .css({
                'font-size': Math.floor(this.scaleFactor * 15).toString() + 'px',
            })
            .bind('click', () => {
                miditools.config.transposeOctave -= 1;
                this._startDNN -= 7;
                this._endDNN -= 7;
                this.redrawSVG();
            });
        const $bUp = $("<button class='keyboardOctaveUp'>&gt;&gt;</button>")
            .css({
                'font-size': Math.floor(this.scaleFactor * 15).toString() + 'px',
            })
            .bind('click', () => {
                miditools.config.transposeOctave += 1;
                this._startDNN += 7;
                this._endDNN += 7;
                this.redrawSVG();
            });
        const $kWrapper = $(
            "<div style='display:inline-block; vertical-align: middle' class='keyboardScrollableInnerDiv'></div>"
        );
        $kWrapper[0].appendChild(svgDOM);
        $wrapper.append($bDown);
        $wrapper.append($kWrapper);
        $wrapper.append($bUp);
        return $wrapper;
    }

    /**
     * Puts a hideable keyboard inside a Div with the proper controls.
     *
     * Do not call this directly, just use createSVG after changing the
     * hideable property on the keyboard to True.
     *
     * @param {Node} where
     * @param {SVGElement} keyboardSVG
     */
    appendHideableKeyboard(where, keyboardSVG) {
        const $container = $("<div class='keyboardHideableContainer'/>");
        const $bInside = $("<div class='keyboardToggleInside'>↥</div>").css({
            display: 'inline-block',
            'padding-top': '40px',
            'font-size': '40px',
        });
        const $b = $("<div class='keyboardToggleOutside'/>").css({
            display: 'inline-block',
            'vertical-align': 'top',
            background: 'white',
        });
        $b.append($bInside);
        $b.data(
            'defaultDisplay',
            $container.find('.keyboardSVG').css('display')
        );
        $b.data('state', 'down');
        $b.click(triggerToggleShow);
        const $explain = $(
            "<div class='keyboardExplain'>Show keyboard</div>"
        ).css({
            display: 'none',
            'background-color': 'white',
            padding: '10px 10px 10px 10px',
            'font-size': '12pt',
        });
        $b.append($explain);
        $container.append($b);
        $container[0].appendChild(keyboardSVG); // svg must use appendChild, not $.append.
        $(where).append($container);
        return $container;
    }
}

// noinspection JSUnusedLocalSymbols
/**
 * triggerToggleShow -- event for keyboard is shown or hidden.
 *
 * @function music21.keyboard.triggerToggleShow
 * @param {Event} [e]
 */
export const triggerToggleShow = e => {
    // "this" refers to the object clicked
    // e -- event is not used.
    const $t = $(this);
    const state = $t.data('state');
    const $parent = $t.parent();
    let $k = $parent.find('.keyboardScrollableWrapper');
    if ($k.length === 0) {
        // not scrollable
        $k = $parent.find('.keyboardSVG');
    }
    const $bInside = $t.find('.keyboardToggleInside');
    const $explain = $parent.find('.keyboardExplain');
    if (state === 'up') {
        $bInside.text('↥');
        $bInside.css('padding-top', '40px');
        $explain.css('display', 'none');
        let dd = $t.data('defaultDisplay');
        if (dd === undefined) {
            dd = 'inline';
        }
        $k.css('display', dd);
        $t.data('state', 'down');
    } else {
        $k.css('display', 'none');
        $explain.css('display', 'inline-block');
        $bInside.text('↧');
        $bInside.css('padding-top', '10px');
        $t.data('state', 'up');
    }
};

/**
 * highlight the keyboard stored in "this" appropriately
 *
 * @function music21.keyboard.jazzHighlight
 * @param {music21.miditools.Event} e
 * @example
 * var midiCallbacksPlay = [music21.miditools.makeChords,
 *                          music21.miditools.sendToMIDIjs,
 *                          music21.keyboard.jazzHighlight.bind(k)];
 */
export function jazzHighlight(e) {
    // e is a miditools.event object -- call with this = keyboard.Keyboard object via bind...
    if (e === undefined) {
        return;
    }
    if (e.noteOn) {
        const midiNote = e.midiNote;
        if (this.keyObjects[midiNote] !== undefined) {
            const keyObj = this.keyObjects[midiNote];
            const svgObj = keyObj.svgObj;
            let intensityRGB = '';
            let normalizedVelocity = (e.velocity + 25) / 127;
            if (normalizedVelocity > 1) {
                normalizedVelocity = 1.0;
            }

            if (keyObj.keyClass === 'whitekey') {
                const intensity = normalizedVelocity.toString();
                intensityRGB = 'rgba(255, 255, 0, ' + intensity + ')';
            } else {
                const intensity = Math.floor(
                    normalizedVelocity * 255
                ).toString();
                intensityRGB = 'rgb(' + intensity + ',' + intensity + ',0)';
                // console.log(intensityRGB);
            }
            svgObj.setAttribute(
                'style',
                'fill:' + intensityRGB + ';stroke:black'
            );
        }
    } else if (e.noteOff) {
        const midiNote = e.midiNote;
        if (this.keyObjects[midiNote] !== undefined) {
            const keyObj = this.keyObjects[midiNote];
            const svgObj = keyObj.svgObj;
            svgObj.setAttribute('style', keyObj.keyStyle);
        }
    }
} // call this with a bind(keyboard.Keyboard object)...
Music21j, Copyright © 2013-2021 Michael Scott Asato Cuthbert.
Documentation generated by JSDoc 3.6.3 on Wed Jul 31st 2019 using the DocStrap template.