media_MidiAudioPlayer.js

/**
 *  File    : media/MidiAudioPlayer.js
 *  Created : 11/10/2018
 *  By      : Francesc Busquets <francesc@gmail.com>
 *
 *  JClic.js
 *  An HTML5 player of JClic activities
 *  https://projectestac.github.io/jclic.js
 *
 *  @source https://github.com/projectestac/jclic.js
 *
 *  @license EUPL-1.2
 *  @licstart
 *  (c) 2000-2020 Educational Telematic Network of Catalonia (XTEC)
 *
 *  Licensed under the EUPL, Version 1.1 or -as soon they will be approved by
 *  the European Commission- subsequent versions of the EUPL (the "Licence");
 *  You may not use this work except in compliance with the Licence.
 *
 *  You may obtain a copy of the Licence at:
 *  https://joinup.ec.europa.eu/software/page/eupl
 *
 *  Unless required by applicable law or agreed to in writing, software
 *  distributed under the Licence is distributed on an "AS IS" basis, WITHOUT
 *  WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
 *  Licence for the specific language governing permissions and limitations
 *  under the Licence.
 *  @licend
 *  @module
 */

/* global window */

import MidiPlayer from '@francesc/basic-midi-player-js';
import { log } from '../Utils';

// TODO: Use multiple instruments, at least one for each track
// TODO: Use multiple midi channels (currently flattened to a single channel)
// TODO: Use of channel 10 for percussion instruments
// TODO: ... build a real MIDI player!!

/**
 * A simple MIDI player based on MidiPlayerJS
 * https://github.com/grimmdude/MidiPlayerJS
 * See also: http://www.midijs.net (https://github.com/babelsberg/babelsberg-js/tree/master/midijs)
 */
export class MidiAudioPlayer {
  /**
   * MidiAudioPlayer constructor
   * @param {external:ArrayBuffer} data - The MIDI file content, in ArrayBuffer format
   * @param {object} [options={}] - Optional params related to the type of soundfont used. Valid options inside this object are:<br>
   * - `MIDISoundFontObject`: An object containing the full soundfont data. When this param is provided, no other one will be used.
   * - `MIDISoundFontBase`: The URL used as base for the current collection of MIDI soundfonts. Defaults to `https://clic.xtec.cat/dist/jclic.js/soundfonts/MusyngKite`
   * - `MIDISoundFontName`: The MIDI instrument name. Defaults to `acoustic_grand_piano`. See [MIDI.js Soundfonts](https://github.com/gleitz/midi-js-soundfonts) for full lists of MIDI instrument names.
   * - `MIDISoundFontExtension`: An extension to be added to `MIDISoundFontName` in order to build the full file name of the soundfont JS file. Defaults to `-mp3.js`
   */
  constructor(data, options = {}) {
    const AudioContext = window && (window.AudioContext || window.webkitAudioContext);
    if (AudioContext) {
      // Build instrument on first call to constructor
      MidiAudioPlayer.prepareInstrument(options, new AudioContext());
      this.data = data;
      this.player = new MidiPlayer.Player(ev => this.playEvent(ev));
      if (this.player)
        this.player.loadArrayBuffer(data);
    }
  }

  /**
   * Initializes the soundfont instrument, loading data from GitHub
   * NOTE: This will not work when off-line!
   * TODO: Provided a basic, simple, static soundfont
   * @param {object} options - Optional param with options related to the MIDI soundfont. See details in `constructor` description.
   * @param {external:AudioContext} audioContext - The AudioContext object (see: https://developer.mozilla.org/en-US/docs/Web/API/AudioContext)
   */
  static prepareInstrument(options = {}, audioContext) {
    if (MidiAudioPlayer.loadingInstrument === false) {
      MidiAudioPlayer.loadingInstrument = true;
      MidiAudioPlayer.audioContext = audioContext;
      MidiPlayer.Soundfont.instrument(
        MidiAudioPlayer.audioContext,
        options.MIDISoundFontObject || MidiAudioPlayer.MIDISoundFontObject ||
        `${options.MIDISoundFontBase || MidiAudioPlayer.MIDISoundFontBase}/${options.MIDISoundFontName || MidiAudioPlayer.MIDISoundFontName}${options.MIDISoundFontExtension || MidiAudioPlayer.MIDISoundFontExtension}`)
        .then(instrument => {
          log('info', 'MIDI soundfont instrument loaded');
          MidiAudioPlayer.instrument = instrument;
        })
        .catch(err => {
          log('error', `Error loading soundfont base instrument: ${err}`);
        });
    }
  }

  /**
   * Pauses the player
   */
  pause() {
    if (this.player) {
      this.player.pause();
      this.startedNotes = [];
    }
  }

  /**
   * Starts or resumes playing
   */
  play() {
    if (this.player) {
      this.startedNotes = [];
      this.player.play();
    }
  }

  /**
   * Gets the ' paused'  state of the current player
   * @returns boolean
   */
  get paused() {
    return this.player && !this.player.isPlaying();
  }

  /**
   * Checks if the current player has ended or is already playing
   * @returns boolean
   */
  get ended() {
    return this.player && this.player.getSongTimeRemaining() <= 0;
  }

  /**
   * Gets the current time
   * @returns number
   */
  get currentTime() {
    return this.player && (this.player.getSongTime() * 1000) || 0;
  }

  /**
   * Sets the current time of this player (in milliseconds)
   * @param {number} time - The time position where the player pointer must be placed
   */
  set currentTime(time) {
    if (this.player)
      this.player.skipToSeconds(time / 1000);
  }

  /**
   * Plays a MIDI event
   * @param {object} ev - The event data. See http://grimmdude.com/MidiPlayerJS/docs/index.html for details
   */
  playEvent(ev) {
    if (this.player && MidiAudioPlayer.instrument) {
      // Check for specific interval
      if (this.playTo > 0 && this.currentTime >= this.playTo)
        this.pause();
      // Set main volume
      else if (ev.name === 'Controller Change' && ev.number === 7)
        this.mainVolume = ev.value / 127;
      // Process 'Note on' messages. Max gain set to 2.0 for better results with the used soundfont
      else if (ev.name === 'Note on' && ev.velocity > 0)
        this.startedNotes[ev.noteNumber] = MidiAudioPlayer.instrument.play(ev.noteName, MidiAudioPlayer.audioContext.currentTime, { gain: 2 * (this.mainVolume * ev.velocity / 100) });
      // Process 'Note off' messages
      else if (ev.name === 'Note off' && ev.noteNumber && this.startedNotes[ev.noteNumber]) {
        this.startedNotes[ev.noteNumber].stop();
        delete (this.startedNotes[ev.noteNumber]);
      }
    }
  }
}

Object.assign(MidiAudioPlayer.prototype, {
  /**
   * The MIDI file data used by this MIDI player
   * @name module:media/MidiAudioPlayer.MidiAudioPlayer#data
   * @type {external:ArrayBuffer} */
  data: null,
  /**
   * The grimmdude's MidiPlayer used by this player
   * @name module:media/MidiAudioPlayer.MidiAudioPlayer#player
   * @type {external:MidiPlayerJS} */
  player: null,
  /**
   * When >0, time position at which the music must end
   * @name module:media/MidiAudioPlayer.MidiAudioPlayer#playTo
   * @type {number} */
  playTo: 0,
  /**
   * Main volume of this track (set with a MIDI message of type `Controller Change` #7)
   * @name module:media/MidiAudioPlayer.MidiAudioPlayer#mainVolume
   * @type {number} */
  mainVolume: 1.0,
  /**
   * This array is used when processing 'Note off' events to stop notes that are currently playing.
   * It contains a collection of 'instrument.play' instances, one for each active note
   * @name module:media/MidiAudioPlayer.MidiAudioPlayer#startedNotes
   * @type {function[]} */
  startedNotes: [],
});

/**
 * The {@link external:AudioContext} used by this MIDI player.
 * @type {external:AudioContext}
 */
MidiAudioPlayer.audioContext = null;

/**
 * The "Instrument" object used by this MIDI player.
 * See: https://github.com/danigb/soundfont-player
 * @type {external:Instrument}
 */
MidiAudioPlayer.instrument = null;

/**
 * A flag used to avoid re-entrant calls to {@link module:media/MidiAudioPlayer.MidiAudioPlayer#prepareInstrument prepareInstrument}
 * @type {boolean}
 */
MidiAudioPlayer.loadingInstrument = false;

/**
 * An object containing the full soundfont data used by {@link module:media/MidiAudioPlayer.MidiAudioPlayer#instrument instrument}
 * When this member is set, no other settings related to the sounfFont will be used.
 * This value can be overwritten by the global parameter `MIDISoundFontObject`
 * @type {object}
 */
MidiAudioPlayer.MIDISoundFontObject = null;

/**
 * The URL used as base for the current collection of MIDI soundfonts.
 * This value can be overwritten by the global parameter `MIDISoundFontBase`
 * @type {string}
 */
MidiAudioPlayer.MIDISoundFontBase = 'https://clic.xtec.cat/dist/jclic.js/soundfonts/MusyngKite';
// Alternative sites are:
// 'https://clic.xtec.cat/dist/jclic.js/soundfonts/FluidR3_GM'
// 'https://raw.githubusercontent.com/gleitz/midi-js-soundfonts/gh-pages/FluidR3_GM'
// 'https://raw.githubusercontent.com/gleitz/midi-js-soundfonts/gh-pages/MusyngKite'

/**
 * The MIDI instrument name.
 * This value can be overwritten by the global parameter `MIDISoundFontName`
 * See [MIDI.js Soundfonts](https://github.com/gleitz/midi-js-soundfonts) for full lists of MIDI instrument names.
 * @type {string}
 */
MidiAudioPlayer.MIDISoundFontName = 'acoustic_grand_piano';

/**
 * An extension to be added to `MIDISoundFontName` in order to build the full file name of the soundfont JS file.
 * Current valid options are `-mp3.js` and `-ogg.js`
 * This value can be overwritten by the global parameter `MIDISoundFontExtension`
 * @type {string}
 */
MidiAudioPlayer.MIDISoundFontExtension = '-mp3.js';

export default MidiAudioPlayer;