html-midi-player
Version:
MIDI file player and visualizer web components
753 lines (600 loc) • 24.2 kB
JavaScript
/**
* html-midi-player@1.5.0
* https://github.com/cifkao/html-midi-player.git
* @author Ondřej Cífka (@cifkao)
* @license BSD-2-Clause
*/
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('@magenta/music/esm/core.js')) :
typeof define === 'function' && define.amd ? define(['exports', '@magenta/music/esm/core.js'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.midiPlayer = {}, global.core));
}(this, (function (exports, mm) { 'use strict';
function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) {
try {
var info = gen[key](arg);
var value = info.value;
} catch (error) {
reject(error);
return;
}
if (info.done) {
resolve(value);
} else {
Promise.resolve(value).then(_next, _throw);
}
}
function _asyncToGenerator(fn) {
return function () {
var self = this,
args = arguments;
return new Promise(function (resolve, reject) {
var gen = fn.apply(self, args);
function _next(value) {
asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value);
}
function _throw(err) {
asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err);
}
_next(undefined);
});
};
}
var playIcon = "<svg width=\"24\" height=\"24\" version=\"1.1\" viewBox=\"0 0 6.35 6.35\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"m4.4979 3.175-2.1167 1.5875v-3.175z\" stroke-width=\".70201\"/>\n</svg>\n";
var pauseIcon = "<svg width=\"24\" height=\"24\" version=\"1.1\" viewBox=\"0 0 6.35 6.35\" xmlns=\"http://www.w3.org/2000/svg\">\n <path d=\"m1.8521 1.5875v3.175h0.92604v-3.175zm1.7198 0v3.175h0.92604v-3.175z\" stroke-width=\".24153\"/>\n</svg>\n";
var errorIcon = "<svg width=\"24\" height=\"24\" version=\"1.1\" viewBox=\"0 0 6.35 6.35\" xmlns=\"http://www.w3.org/2000/svg\">\n <path transform=\"scale(.26458)\" d=\"m12 3.5a8.4993 8.4993 0 0 0-8.5 8.5 8.4993 8.4993 0 0 0 8.5 8.5 8.4993 8.4993 0 0 0 8.5-8.5 8.4993 8.4993 0 0 0-8.5-8.5zm-1.4062 3.5h3v6h-3v-6zm0 8h3v2h-3v-2z\"/>\n</svg>\n";
var controlsCSS = ":host {\n display: inline-block;\n width: 300px;\n margin: 3px;\n vertical-align: bottom;\n font-family: sans-serif;\n font-size: 14px;\n}\n\n:focus:not(.focus-visible) {\n outline: none;\n}\n\n.controls {\n width: inherit;\n height: inherit;\n box-sizing: border-box;\n display: flex;\n flex-direction: row;\n position: relative;\n overflow: hidden;\n align-items: center;\n border-radius: 100px;\n background: #f2f5f6;\n padding: 0 0.25em;\n user-select: none;\n}\n.controls > * {\n margin: 0.8em 0.45em;\n}\n.controls input, .controls button {\n cursor: pointer;\n}\n.controls input:disabled, .controls button:disabled {\n cursor: inherit;\n}\n.controls button {\n text-align: center;\n background: rgba(204, 204, 204, 0);\n border: none;\n width: 32px;\n height: 32px;\n border-radius: 100%;\n transition: background-color 0.25s ease 0s;\n padding: 0;\n}\n.controls button:not(:disabled):hover {\n background: rgba(204, 204, 204, 0.3);\n}\n.controls button:not(:disabled):active {\n background: rgba(204, 204, 204, 0.6);\n}\n.controls button .icon {\n display: none;\n}\n.controls button .icon, .controls button .icon svg {\n vertical-align: middle;\n}\n.controls button .icon svg {\n fill: currentColor;\n}\n.controls .seek-bar {\n flex: 1;\n min-width: 0;\n margin-right: 1.1em;\n background: transparent;\n}\n.controls .seek-bar::-moz-range-track {\n background-color: #555;\n}\n.controls.stopped .play-icon, .controls.playing .stop-icon, .controls.error .error-icon {\n display: inherit;\n}\n.controls.frozen > div, .controls > button:disabled .icon {\n opacity: 0.5;\n}\n.controls .overlay {\n z-index: 0;\n position: absolute;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n margin: 0;\n box-sizing: border-box;\n display: none;\n opacity: 1;\n}\n.controls.loading .loading-overlay {\n display: block;\n background: linear-gradient(110deg, #92929200 5%, #92929288 25%, #92929200 45%);\n background-size: 250% 100%;\n background-repeat: repeat-y;\n animation: shimmer 1.5s linear infinite;\n}\n\n@keyframes shimmer {\n 0% {\n background-position: 125% 0;\n }\n 100% {\n background-position: -200% 0;\n }\n}";
var visualizerCSS = ":host {\n display: block;\n}\n\n::slotted(.piano-roll-visualizer) {\n overflow-x: auto;\n}";
/// <reference path="imports.d.ts"/>
var controlsTemplate = document.createElement('template');
controlsTemplate.innerHTML = "\n<style>\n".concat(controlsCSS, "\n</style>\n<div class=\"controls stopped frozen\" part=\"control-panel\">\n <button class=\"play\" part=\"play-button\" disabled>\n <span class=\"icon play-icon\">").concat(playIcon, "</span>\n <span class=\"icon stop-icon\">").concat(pauseIcon, "</span>\n <span class=\"icon error-icon\">").concat(errorIcon, "</span>\n </button>\n <div part=\"time\"><span class=\"current-time\" part=\"current-time\">0:00</span> / <span class=\"total-time\" part=\"total-time\">0:00</span></div>\n <input type=\"range\" min=\"0\" max=\"0\" value=\"0\" step=\"any\" class=\"seek-bar\" part=\"seek-bar\" disabled>\n <div class=\"overlay loading-overlay\" part=\"loading-overlay\"></div>\n</div>\n");
var visualizerTemplate = document.createElement('template');
visualizerTemplate.innerHTML = "\n<style>\n".concat(visualizerCSS, "\n</style>\n<slot>\n</slot>\n");
function formatTime(seconds) {
var negative = seconds < 0;
seconds = Math.floor(Math.abs(seconds || 0));
var s = seconds % 60;
var m = (seconds - s) / 60;
var h = (seconds - s - 60 * m) / 3600;
var sStr = s > 9 ? "".concat(s) : "0".concat(s);
var mStr = m > 9 || !h ? "".concat(m, ":") : "0".concat(m, ":");
var hStr = h ? "".concat(h, ":") : '';
return (negative ? '-' : '') + hStr + mStr + sStr;
}
var VISUALIZER_TYPES = ['piano-roll', 'waterfall', 'staff'];
/**
* MIDI visualizer element.
*
* The visualizer is implemented via SVG elements which support styling as described
* [here](https://magenta.github.io/magenta-js/music/demos/visualizer.html).
*
* See also the
* [`@magenta/music/core/visualizer` docs](https://magenta.github.io/magenta-js/music/modules/_core_visualizer_.html).
*
* @prop src - MIDI file URL
* @prop type - Visualizer type
* @prop noteSequence - Magenta note sequence object representing the currently displayed content
* @prop config - Magenta visualizer config object
*/
class VisualizerElement extends HTMLElement {
constructor() {
super(...arguments);
this.domInitialized = false;
this.ns = null;
this._config = {};
}
static get observedAttributes() {
return ['src', 'type'];
}
connectedCallback() {
this.attachShadow({
mode: 'open'
});
this.shadowRoot.appendChild(visualizerTemplate.content.cloneNode(true));
if (this.domInitialized) {
return;
}
this.domInitialized = true;
this.wrapper = document.createElement('div');
this.appendChild(this.wrapper);
this.initVisualizerNow();
}
attributeChangedCallback(name, _oldValue, _newValue) {
if (name === 'src' || name === 'type') {
this.initVisualizer();
}
}
initVisualizer() {
if (this.initTimeout == null) {
this.initTimeout = window.setTimeout(() => this.initVisualizerNow());
}
}
initVisualizerNow() {
var _this = this;
return _asyncToGenerator(function* () {
_this.initTimeout = null;
if (!_this.domInitialized) {
return;
}
if (_this.src) {
_this.ns = null;
_this.ns = yield mm.urlToNoteSequence(_this.src);
}
_this.wrapper.innerHTML = '';
if (!_this.ns) {
return;
}
if (_this.type === 'piano-roll') {
_this.wrapper.classList.add('piano-roll-visualizer');
var svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
_this.wrapper.appendChild(svg);
_this.visualizer = new mm.PianoRollSVGVisualizer(_this.ns, svg, _this._config);
} else if (_this.type === 'waterfall') {
_this.wrapper.classList.add('waterfall-visualizer');
_this.visualizer = new mm.WaterfallSVGVisualizer(_this.ns, _this.wrapper, _this._config);
} else if (_this.type === 'staff') {
_this.wrapper.classList.add('staff-visualizer');
var div = document.createElement('div');
_this.wrapper.appendChild(div);
_this.visualizer = new mm.StaffSVGVisualizer(_this.ns, div, _this._config);
}
})();
}
reload() {
this.initVisualizerNow();
}
redraw(activeNote) {
if (this.visualizer) {
this.visualizer.redraw(activeNote, activeNote != null);
}
}
clearActiveNotes() {
if (this.visualizer) {
this.visualizer.clearActiveNotes();
}
}
get noteSequence() {
return this.ns;
}
set noteSequence(value) {
if (this.ns == value) {
return;
}
this.ns = value;
this.removeAttribute('src'); // Triggers initVisualizer only if src was present.
this.initVisualizer();
}
get src() {
return this.getAttribute('src');
}
set src(value) {
this.ns = null;
this.setOrRemoveAttribute('src', value); // Triggers initVisualizer only if src was present.
this.initVisualizer();
}
get type() {
var value = this.getAttribute('type');
if (VISUALIZER_TYPES.indexOf(value) < 0) {
value = 'piano-roll';
}
return value;
}
set type(value) {
if (value != null && VISUALIZER_TYPES.indexOf(value) < 0) {
throw new Error("Unknown visualizer type ".concat(value, ". Allowed values: ").concat(VISUALIZER_TYPES.join(', ')));
}
this.setOrRemoveAttribute('type', value);
}
get config() {
return this._config;
}
set config(value) {
this._config = value;
this.initVisualizer();
}
setOrRemoveAttribute(name, value) {
if (value == null) {
this.removeAttribute(name);
} else {
this.setAttribute(name, value);
}
}
}
var VISUALIZER_EVENTS = ['start', 'stop', 'note'];
var DEFAULT_SOUNDFONT = 'https://storage.googleapis.com/magentadata/js/soundfonts/sgm_plus';
var playingPlayer = null;
/**
* MIDI player element.
* See also the [`@magenta/music/core/player` docs](https://magenta.github.io/magenta-js/music/modules/_core_player_.html).
*
* The element supports styling using the CSS [`::part` syntax](https://developer.mozilla.org/docs/Web/CSS/::part)
* (see the list of shadow parts [below](#css-shadow-parts)). For example:
* ```css
* midi-player::part(control-panel) {
* background: aquamarine;
* border-radius: 0px;
* }
* ```
*
* @prop src - MIDI file URL
* @prop soundFont - Magenta SoundFont URL, an empty string to use the default SoundFont, or `null` to use a simple oscillator synth
* @prop noteSequence - Magenta note sequence object representing the currently loaded content
* @prop loop - Indicates whether the player should loop
* @prop currentTime - Current playback position in seconds
* @prop duration - Content duration in seconds
* @prop playing - Indicates whether the player is currently playing
* @attr visualizer - A selector matching `midi-visualizer` elements to bind to this player
*
* @fires load - The content is loaded and ready to play
* @fires start - The player has started playing
* @fires stop - The player has stopped playing
* @fires loop - The player has automatically restarted playback after reaching the end
* @fires note - A note starts
*
* @csspart control-panel - `<div>` containing all the controls
* @csspart play-button - Play button
* @csspart time - Numeric time indicator
* @csspart current-time - Elapsed time
* @csspart total-time - Total duration
* @csspart seek-bar - `<input type="range">` showing playback position
* @csspart loading-overlay - Overlay with shimmer animation
*/
class PlayerElement extends HTMLElement {
constructor() {
super();
this.domInitialized = false;
this.needInitNs = false;
this.visualizerListeners = new Map();
this.ns = null;
this._playing = false;
this.seeking = false;
this.attachShadow({
mode: 'open'
});
this.shadowRoot.appendChild(controlsTemplate.content.cloneNode(true));
this.controlPanel = this.shadowRoot.querySelector('.controls');
this.playButton = this.controlPanel.querySelector('.play');
this.currentTimeLabel = this.controlPanel.querySelector('.current-time');
this.totalTimeLabel = this.controlPanel.querySelector('.total-time');
this.seekBar = this.controlPanel.querySelector('.seek-bar');
}
static get observedAttributes() {
return ['sound-font', 'src', 'visualizer'];
}
connectedCallback() {
if (this.domInitialized) {
return;
}
this.domInitialized = true;
var applyFocusVisiblePolyfill = window.applyFocusVisiblePolyfill;
if (applyFocusVisiblePolyfill != null) {
applyFocusVisiblePolyfill(this.shadowRoot);
}
this.playButton.addEventListener('click', () => {
if (this.player.isPlaying()) {
this.stop();
} else {
this.start();
}
});
this.seekBar.addEventListener('input', () => {
// Pause playback while the user is manipulating the control
this.seeking = true;
if (this.player && this.player.getPlayState() === 'started') {
this.player.pause();
}
});
this.seekBar.addEventListener('change', () => {
var time = this.currentTime; // This returns the seek bar value as a number
this.currentTimeLabel.textContent = formatTime(time);
if (this.player) {
if (this.player.isPlaying()) {
this.player.seekTo(time);
if (this.player.getPlayState() === 'paused') {
this.player.resume();
}
}
}
this.seeking = false;
});
this.initPlayerNow();
}
attributeChangedCallback(name, _oldValue, newValue) {
if (!this.hasAttribute(name)) {
newValue = null;
}
if (name === 'sound-font' || name === 'src') {
this.initPlayer();
} else if (name === 'visualizer') {
var fn = () => {
this.setVisualizerSelector(newValue);
};
if (document.readyState === 'loading') {
window.addEventListener('DOMContentLoaded', fn);
} else {
fn();
}
}
}
initPlayer(initNs = true) {
this.needInitNs = this.needInitNs || initNs;
if (this.initTimeout == null) {
this.stop();
this.setLoading();
this.initTimeout = window.setTimeout(() => this.initPlayerNow(this.needInitNs));
}
}
initPlayerNow(initNs = true) {
var _this = this;
return _asyncToGenerator(function* () {
_this.initTimeout = null;
_this.needInitNs = false;
if (!_this.domInitialized) {
return;
}
try {
var ns = null;
if (initNs) {
if (_this.src) {
_this.ns = null;
_this.ns = yield mm.urlToNoteSequence(_this.src);
}
_this.currentTime = 0;
if (!_this.ns) {
_this.setError('No content loaded');
}
}
ns = _this.ns;
if (ns) {
_this.seekBar.max = String(ns.totalTime);
_this.totalTimeLabel.textContent = formatTime(ns.totalTime);
} else {
_this.seekBar.max = '0';
_this.totalTimeLabel.textContent = formatTime(0);
return;
}
var soundFont = _this.soundFont;
var callbackObject = {
// Call callbacks only if we are still playing the same note sequence.
run: n => _this.ns === ns && _this.noteCallback(n),
stop: () => {}
};
if (soundFont === null) {
_this.player = new mm.Player(false, callbackObject);
} else {
if (soundFont === "") {
soundFont = DEFAULT_SOUNDFONT;
}
_this.player = new mm.SoundFontPlayer(soundFont, undefined, undefined, undefined, callbackObject);
yield _this.player.loadSamples(ns);
}
if (_this.ns !== ns) {
// If we started loading a different sequence in the meantime...
return;
}
_this.setLoaded();
_this.dispatchEvent(new CustomEvent('load'));
} catch (error) {
_this.setError(String(error));
throw error;
}
})();
}
reload() {
this.initPlayerNow();
}
start() {
this._start();
}
_start(looped = false) {
var _this2 = this;
_asyncToGenerator(function* () {
if (_this2.player) {
if (_this2.player.getPlayState() == 'stopped') {
if (playingPlayer && playingPlayer.playing && !(playingPlayer == _this2 && looped)) {
playingPlayer.stop();
}
playingPlayer = _this2;
_this2._playing = true;
var offset = _this2.currentTime; // Jump to the start if there are no notes left to play.
if (_this2.ns.notes.filter(note => note.startTime > offset).length == 0) {
offset = 0;
}
_this2.currentTime = offset;
_this2.controlPanel.classList.remove('stopped');
_this2.controlPanel.classList.add('playing');
try {
// Force reload visualizers to prevent stuttering at playback start
for (var visualizer of _this2.visualizerListeners.keys()) {
if (visualizer.noteSequence != _this2.ns) {
visualizer.noteSequence = _this2.ns;
visualizer.reload();
}
}
var promise = _this2.player.start(_this2.ns, undefined, offset);
if (!looped) {
_this2.dispatchEvent(new CustomEvent('start'));
} else {
_this2.dispatchEvent(new CustomEvent('loop'));
}
yield promise;
_this2.handleStop(true);
} catch (error) {
_this2.handleStop();
throw error;
}
} else if (_this2.player.getPlayState() == 'paused') {
// This normally should not happen, since we pause playback only when seeking.
_this2.player.resume();
}
}
})();
}
stop() {
if (this.player && this.player.isPlaying()) {
this.player.stop();
}
this.handleStop(false);
}
addVisualizer(visualizer) {
var listeners = {
start: () => {
visualizer.noteSequence = this.noteSequence;
},
stop: () => {
visualizer.clearActiveNotes();
},
note: event => {
visualizer.redraw(event.detail.note);
}
};
for (var name of VISUALIZER_EVENTS) {
this.addEventListener(name, listeners[name]);
}
this.visualizerListeners.set(visualizer, listeners);
}
removeVisualizer(visualizer) {
var listeners = this.visualizerListeners.get(visualizer);
for (var name of VISUALIZER_EVENTS) {
this.removeEventListener(name, listeners[name]);
}
this.visualizerListeners.delete(visualizer);
}
noteCallback(note) {
if (!this.playing) {
return;
}
this.dispatchEvent(new CustomEvent('note', {
detail: {
note
}
}));
if (this.seeking) {
return;
}
this.seekBar.value = String(note.startTime);
this.currentTimeLabel.textContent = formatTime(note.startTime);
}
handleStop(finished = false) {
if (finished) {
if (this.loop) {
this.currentTime = 0;
this._start(true);
return;
}
this.currentTime = this.duration;
}
this.controlPanel.classList.remove('playing');
this.controlPanel.classList.add('stopped');
if (this._playing) {
this._playing = false;
this.dispatchEvent(new CustomEvent('stop', {
detail: {
finished
}
}));
}
}
setVisualizerSelector(selector) {
// Remove old listeners
for (var listeners of this.visualizerListeners.values()) {
for (var name of VISUALIZER_EVENTS) {
this.removeEventListener(name, listeners[name]);
}
}
this.visualizerListeners.clear(); // Match visualizers and add them as listeners
if (selector != null) {
for (var element of document.querySelectorAll(selector)) {
if (!(element instanceof VisualizerElement)) {
console.warn("Selector ".concat(selector, " matched non-visualizer element"), element);
continue;
}
this.addVisualizer(element);
}
}
}
setLoading() {
this.playButton.disabled = true;
this.seekBar.disabled = true;
this.controlPanel.classList.remove('error');
this.controlPanel.classList.add('loading', 'frozen');
this.controlPanel.removeAttribute('title');
}
setLoaded() {
this.controlPanel.classList.remove('loading', 'frozen');
this.playButton.disabled = false;
this.seekBar.disabled = false;
}
setError(error) {
this.playButton.disabled = true;
this.seekBar.disabled = true;
this.controlPanel.classList.remove('loading', 'stopped', 'playing');
this.controlPanel.classList.add('error', 'frozen');
this.controlPanel.title = error;
}
get noteSequence() {
return this.ns;
}
set noteSequence(value) {
if (this.ns == value) {
return;
}
this.ns = value;
this.removeAttribute('src'); // Triggers initPlayer only if src was present.
this.initPlayer();
}
get src() {
return this.getAttribute('src');
}
set src(value) {
this.ns = null;
this.setOrRemoveAttribute('src', value); // Triggers initPlayer only if src was present.
this.initPlayer();
}
/**
* @attr sound-font
*/
get soundFont() {
return this.getAttribute('sound-font');
}
set soundFont(value) {
this.setOrRemoveAttribute('sound-font', value);
}
/**
* @attr loop
*/
get loop() {
return this.getAttribute('loop') != null;
}
set loop(value) {
this.setOrRemoveAttribute('loop', value ? '' : null);
}
get currentTime() {
return parseFloat(this.seekBar.value);
}
set currentTime(value) {
this.seekBar.value = String(value);
this.currentTimeLabel.textContent = formatTime(this.currentTime);
if (this.player && this.player.isPlaying()) {
this.player.seekTo(value);
}
}
get duration() {
return parseFloat(this.seekBar.max);
}
get playing() {
return this._playing;
}
setOrRemoveAttribute(name, value) {
if (value == null) {
this.removeAttribute(name);
} else {
this.setAttribute(name, value);
}
}
}
window.customElements.define('midi-player', PlayerElement);
window.customElements.define('midi-visualizer', VisualizerElement);
exports.PlayerElement = PlayerElement;
exports.VisualizerElement = VisualizerElement;
Object.defineProperty(exports, '__esModule', { value: true });
})));