Source

components/Music/Music.js

// NPM module imports
import React, { Component, createRef } from "react";
import { Switch, Route, withRouter } from "react-router-dom";
import PropTypes from "prop-types";
import { connect } from "react-redux";

// Component imports
import PracticeMusicHeader from "./PracticeMusicHeader/PracticeMusicHeader";
import MusicPerformanceHeader from "./MusicPerformanceHeader/MusicPerformanceHeader";
import PageHeader from "../PageHeader/PageHeader";
import LoadingContainer from "../Spinners/LoadingContainer/LoadingContainer";

// File imports
import initializeAlphaTabApi from "../../vendors/AlphaTab/initialization";
import destroyAlphaTabApi from "../../vendors/AlphaTab/destruction";
import alphaTabVars from "../../vendors/AlphaTab/variables";
import {
    changeToSheetMusic,
    changePart,
    loadJustMyPart,
    loadTex,
    changeToPerformance,
    changeToExercise,
    stopPlayingMusic,
} from "../../vendors/AlphaTab/actions";
import { getMyPart, getPartList } from "../../vendors/AlphaTab/actions";
import setupPitchDetection from "../../vendors/ML5/PitchDetection/initialization";
import { sheetMusicError } from "../../vendors/Firebase/logs";
import * as alertBarTypes from "../AlertBar/alertBarTypes";
import * as musicViewOptions from "./musicViewOptions";
import { exerciseGenerated } from "../../store/actions";

// Style imports
import "./SheetMusic.scss";
import styles from "./Music.module.scss";

/**
 * Renders the Music component.
 * This component handles practicing music, viewing performance, and practicing an exercise.
 * @component
 * @category Music
 * @author Dan Levy <danlevy124@gmail.com>
 */
class Music extends Component {
    /**
     * Music component state
     * @property {boolean} isAlphaTabLoading - Indicates if AlphaTab is in a loading state
     * @property {boolean} isPitchDetectionLoading - Indicates if pitch detection is in a loading state
     * @property {boolean} isDataLoading - Indicates if data is being downloaded
     * @property {object} currentPart - The currently selected part (track) of the sheet music (e.g. Alto)
     * @property {array} partList - The list of all available parts (tracks) for the sheet music
     * @property {boolean} isMicrophoneAvailable - Indicates if a microphone is available for use
     * @property {string} numberOfMeasures - The number of measures in the sheet music
     * @property {module:musicViewOptions} currentView - The current view that this component is displaying (e.g. performance)
     */
    state = {
        isAlphaTabLoading: true,
        isPitchDetectionLoading: true,
        isDataLoading: true,
        currentPart: null,
        partList: null,
        isMicrophoneAvailable: true,
        numberOfMeasures: "0",
        currentView: null,
    };

    /**
     * Indicates if the component is mounted.
     * Used for asynchronous tasks.
     * @type {boolean}
     * @see https://reactjs.org/blog/2015/12/16/ismounted-antipattern.html
     */
    _isMounted = false;

    /**
     * A reference to the AlphaTab wrapper element.
     * Uses a React Ref.
     */
    _alphaTabWrapperRef = createRef();

    /**
     * Sets _isMounted to true.
     * Initializes the AlphaTab API.
     * Initializes pitch detection.
     * Prepares the music.
     */
    componentDidMount() {
        this._isMounted = true;

        initializeAlphaTabApi();
        alphaTabVars.getsFeedback = this.props.doesUserGetFeedback;
        alphaTabVars.api.addPostRenderFinished(this.alphaTabDidRender);

        if (this.props.doesUserGetFeedback) {
            this.initializePitchDetection();
        } else {
            if (this._isMounted) {
                this.setState({
                    isPitchDetectionLoading: false,
                    isMicrophoneAvailable: false,
                });
            }
        }
        this.prepareMusic();
    }

    /**
     * Determines the current view based on the current url
     */
    static getDerivedStateFromProps(newProps) {
        // Gets the string after the last forward slash (/) in the url and sets it to be the page type
        return {
            currentView: newProps.location.pathname.substring(
                newProps.location.pathname.lastIndexOf("/") + 1
            ),
        };
    }

    /**
     * Prepares the music again if the current view changed.
     * @param {object} _ The previous component props
     * @param {object} prevState The previous component state
     */
    componentDidUpdate(_, prevState) {
        if (prevState.currentView !== this.state.currentView) {
            this.prepareMusic();
        }
    }

    /**
     * Destroys the AlphaTab API
     */
    componentWillUnmount() {
        this._isMounted = false;
        destroyAlphaTabApi();
        alphaTabVars.api.removePostRenderFinished(this.alphaTabDidRender);
    }

    /**
     * Updates state when AlphaTab is done rendering
     * @function
     */
    alphaTabDidRender = () => {
        if (this._isMounted) {
            this.setState({ isAlphaTabLoading: false });
        }
    };

    /**
     * Gets the piece of sheet music.
     * Renders AlphaTab.
     * @function
     * @async
     * @returns {Promise} A promise
     */
    prepareMusic = async () => {
        if (this._isMounted) {
            this.setState({ isAlphaTabLoading: true, isDataLoading: true });
        }
        let loadSheetMusic;

        // Chooses the correct sheet music and drawing based on the given currentView prop
        switch (this.state.currentView) {
            case musicViewOptions.PRACTICE:
                loadSheetMusic = changeToSheetMusic;
                break;
            case musicViewOptions.PERFORMANCE:
                loadSheetMusic = changeToPerformance;
                break;
            case musicViewOptions.EXERCISE:
                loadSheetMusic = changeToExercise.bind(
                    this,
                    parseInt(this.props.exercise.startMeasure, 10),
                    parseInt(this.props.exercise.endMeasure, 10)
                );
                break;
            default:
                console.log(
                    `${this.state.currentView} is not valid. See musicViewOptions.js for options. No music was loaded.`
                );
        }

        try {
            // Loads the sheet music
            await loadSheetMusic();

            if (this.state.currentView === musicViewOptions.EXERCISE) {
                // Tells Redux that an exercise has been generated
                this.props.exerciseGenerated();
            }

            // Updates state with the sheet music data
            if (this._isMounted) {
                this.setState({
                    isDataLoading: false,
                    currentPart: getMyPart(),
                    partList: ["Just My Part"].concat(getPartList()),
                    numberOfMeasures: alphaTabVars.texLoaded.measureLengths.length.toString(),
                });
            }
        } catch (error) {
            // Logs an error
            sheetMusicError(null, error, "[Music/prepareMusic]");

            // Shows an alert
            this.props.showAlert(
                alertBarTypes.ERROR,
                "Error",
                "We can't load the sheet music right now. Please try again later."
            );
        }
    };

    /**
     * Sets up ML5 pitch detection
     * @function
     * @async
     * @returns {Promise} A promise
     */
    initializePitchDetection = async () => {
        // Prepares for microphone input sets up the pitch detection model
        try {
            // Sets up pitch detection
            await setupPitchDetection();

            // Timeout gives extra time for the microphone and pitch detection to set up
            // There appears to be UI-blocking code even though the async code waits
            setTimeout(() => {
                if (this._isMounted) {
                    this.setState({
                        isPitchDetectionLoading: false,
                        isMicrophoneAvailable: true,
                    });
                }
            }, 2000);
        } catch (error) {
            // Logs an error
            sheetMusicError(
                null,
                error,
                "[components/Music/initializePitchDetection]"
            );

            // Shows an alert
            this.props.showAlert(
                alertBarTypes.WARNING,
                "No Microphone",
                "Please connect a microphone and/or give us permission to access your microphone. Music playback is still allowed, but a microphone is required for feedback."
            );

            // Updates state
            if (this._isMounted) {
                this.setState({ isMicrophoneAvailable: false });
            }
        }
    };

    /**
     * Switches to a new music page.
     * See [options]{@link module:musicViewOptions}.
     * @function
     * @param {module:musicViewOptions} newView - The view to switch to
     */
    switchToNewMusicPage = (newView) => {
        // Prepares for a sheet music update
        this.prepareForSheetMusicUpdate();

        // Updates the URL
        const routeUrl = this.getNewUrl(newView);
        this.props.history.replace(routeUrl);
    };

    /**
     * Prepares for a page switch.
     * Updates AlphaTab and updates state.
     * @function
     */
    prepareForSheetMusicUpdate = () => {
        // Stops playing the music
        stopPlayingMusic();

        // Moves the sheet music back to the beginning (measure 1)
        this._alphaTabWrapperRef.current.scrollLeft = 0;

        // Updates state
        if (this._isMounted) {
            this.setState({ isAlphaTabLoading: true, isDataLoading: true });
        }
    };

    /**
     * Gets a new URL based on the new view
     * @function
     * @param {module:musicViewOptions} newView - The view that is being switched to
     */
    getNewUrl = (newView) => {
        // Replaces the string after the last forward slash (/) in the url with the new page type
        return `${this.props.location.pathname.substring(
            0,
            this.props.location.pathname.lastIndexOf("/")
        )}/${newView}`;
    };

    /**
     * Changes the track number in AlphaTab to the new index.
     * Updates state to reflect the new part value.
     * @function
     * @param {number} index - The index of the selected part based on the select input
     * @param {string} value - The value (name) of the selected part
     */
    onPartChangeHandler = async (index, value) => {
        // Prepares for a sheet music update
        this.prepareForSheetMusicUpdate();

        if (this.state.currentPart === "Just My Part") {
            // Because the current part removed all other parts from the music, we need to reload those other parts
            // NOTE: The current part is not the new part (the new part is "value")
            await loadTex(value);
        } else if (index === 0) {
            // Loads "just my part"
            await loadJustMyPart();
        } else {
            // Changes the part
            // Because the "Just My Part" option is not directly included in the track list, but is the first option in the select menu (index 0), we need to get index - 1 from the track list
            await changePart(`t${index - 1}`);
        }

        // Updates state with the new part
        if (this._isMounted) {
            this.setState({
                isDataLoading: false,
                currentPart: value,
                numberOfMeasures: alphaTabVars.texLoaded.measureLengths.length.toString(),
            });
        }
    };

    /**
     * Gets a loading component
     * @function
     * @returns A loading component (JSX)
     */
    getLoadingComponent = () => {
        // Gets the loading message
        let message;
        switch (this.state.currentView) {
            case musicViewOptions.PRACTICE:
                message = "Loading music...";
                break;
            case musicViewOptions.PERFORMANCE:
                message = "Loading performance...";
                break;
            case musicViewOptions.EXERCISE:
                message = "Loading exercise...";
                break;
            default:
                message = "Loading music...";
        }

        // Returns the loading component
        return (
            <div className={styles.musicLoadingContainer}>
                <LoadingContainer message={message} />
            </div>
        );
    };

    /**
     * Gets a page header component
     * @function
     * @returns A page header component (JSX)
     */
    getPageHeaderComponent = () => {
        // Gets the match url from React Router
        // The match url is the url that has been matched so far (this is a nested route)
        const matchUrl = this.props.match.url;

        // Returns the correct page header based on the url
        return (
            <Switch>
                <Route path={`${matchUrl}/practice`}>
                    <PracticeMusicHeader
                        currentView={this.state.currentView}
                        currentPart={this.state.currentPart}
                        partList={this.state.partList}
                        onPartChange={this.onPartChangeHandler}
                        switchToPerformance={() =>
                            this.switchToNewMusicPage(
                                musicViewOptions.PERFORMANCE
                            )
                        }
                        doesUserGetFeedback={this.props.doesUserGetFeedback}
                    />
                </Route>
                <Route path={`${matchUrl}/performance`}>
                    <MusicPerformanceHeader
                        numberOfMeasures={this.state.numberOfMeasures}
                        switchToPractice={() =>
                            this.switchToNewMusicPage(musicViewOptions.PRACTICE)
                        }
                        switchToExercise={() =>
                            this.switchToNewMusicPage(musicViewOptions.EXERCISE)
                        }
                    />
                </Route>
                <Route path={`${matchUrl}/exercise`}>
                    <PracticeMusicHeader
                        currentView={this.state.currentView}
                        switchToPractice={() =>
                            this.switchToNewMusicPage(musicViewOptions.PRACTICE)
                        }
                        switchToPerformance={() =>
                            this.switchToNewMusicPage(
                                musicViewOptions.PERFORMANCE
                            )
                        }
                        doesUserGetFeedback={true}
                    />
                </Route>
            </Switch>
        );
    };

    /**
     * Gets a page heading
     * @function
     * @returns {string} A page heading
     */
    getPageHeading = () => {
        switch (this.state.currentView) {
            case musicViewOptions.PRACTICE:
                if (this.props.doesUserGetFeedback) {
                    return this.state.isMicrophoneAvailable
                        ? "Practice"
                        : "Playback - No Microphone Available";
                } else {
                    return "Practice - No Feedback";
                }

            case musicViewOptions.PERFORMANCE:
                return "Performance";
            case musicViewOptions.EXERCISE:
                return this.state.isMicrophoneAvailable
                    ? `Exercise (Measures ${this.props.exercise.startMeasure} - ${this.props.exercise.endMeasure})`
                    : `Exercise Playback (Measures ${this.props.exercise.startMeasure} - ${this.props.exercise.endMeasure}) - No Microphone Available`;
            default:
                return "Practice";
        }
    };

    /**
     * Renders the Music component.
     * The sketch and AlphaTab are not handled by React, but rather by via direct DOM manipulation.
     * See the vendors folder for the P5 (sketch) code and the AlphaTab code
     */
    render() {
        // Combines loading states into one isLoading boolean
        const isLoading =
            this.state.isAlphaTabLoading ||
            this.state.isPitchDetectionLoading ||
            this.state.isDataLoading;

        // Gets the correct component
        let component = isLoading
            ? this.getLoadingComponent()
            : this.getPageHeaderComponent();

        // Returns the JSX to display
        return (
            <main className={styles.music}>
                <PageHeader
                    heading={this.getPageHeading()}
                    shouldDisplayBackButton={true}
                    backButtonTitle={"Music Selection"}
                />
                <div className={styles.musicMain}>
                    {/* A loading component or a page header component */}
                    {component}

                    {/* These elements are not handled directly by React (see vendors folder for code) */}
                    <section
                        id="alpha-tab-wrapper"
                        ref={this._alphaTabWrapperRef}
                    >
                        {/* Sketch (real-time feedback or performance highlighting) */}
                        <div id="sketch-holder"></div>

                        {/* Sheet music */}
                        <div id="alpha-tab-container"></div>
                    </section>
                </div>
            </main>
        );
    }
}

// Prop types for the Music component
Music.propTypes = {
    /**
     * The current URL data.
     * This is provided by the withRouter function.
     */
    location: PropTypes.object.isRequired,

    /**
     * The history object.
     * This is provided by the withRouter function.
     */
    history: PropTypes.object.isRequired,

    /**
     * The matched URL.
     * This is provided by the withRouter function.
     */
    match: PropTypes.object.isRequired,

    /**
     * Indicates if the user gets feedback
     */
    doesUserGetFeedback: PropTypes.bool.isRequired,

    /**
     * The requested exercise measures (if an exercise was requested)
     */
    exercise: PropTypes.shape({
        startMeasure: PropTypes.string,
        endMeasure: PropTypes.string,
    }),

    /**
     * Shows an alert
     */
    showAlert: PropTypes.func.isRequired,

    /**
     * Tells Redux that the requested exercise has been generated
     */
    exerciseGenerated: PropTypes.func.isRequired,
};

/**
 * Gets the current state from Redux and passes parts of it to the Music component as props.
 * This function is used only by the react-redux connect function.
 * @memberof Music
 * @param {object} state - The Redux state
 * @returns {object} Redux state properties used in the Music component
 */
const mapStateToProps = (state) => {
    return {
        exercise: state.practice.exercise,
        doesUserGetFeedback: state.practice.doesUserGetFeedback,
    };
};

/**
 * Passes certain Redux actions to the Music component as props.
 * This function is used only by the react-redux connect function.
 * @memberof Music
 * @param {function} dispatch - The react-redux dispatch function
 * @returns {object} Redux actions used in the Music component
 */
const mapDispatchToProps = (dispatch) => {
    return {
        exerciseGenerated: () => dispatch(exerciseGenerated()),
    };
};

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(Music));