From db76cef2394975c0143cd4eabe9b5731594c5efb Mon Sep 17 00:00:00 2001 From: Jordan Eldredge Date: Fri, 6 Sep 2019 06:35:43 -0700 Subject: [PATCH] Hack timidity to allow for access to the audio context/destination --- demo/timidity/bundle.js | 407 +++++++++++++++++++------------------ demo/timidity/index.js | 413 +++++++++++++++++++------------------- js/media/elementSource.ts | 48 +---- 3 files changed, 429 insertions(+), 439 deletions(-) diff --git a/demo/timidity/bundle.js b/demo/timidity/bundle.js index 572896e4..7751135c 100644 --- a/demo/timidity/bundle.js +++ b/demo/timidity/bundle.js @@ -1,402 +1,411 @@ (function(f){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=f()}else if(typeof define==="function"&&define.amd){define([],f)}else{var g;if(typeof window!=="undefined"){g=window}else if(typeof global!=="undefined"){g=global}else if(typeof self!=="undefined"){g=self}else{g=this}g.Timidity = f()}})(function(){var define,module,exports;return (function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,function(r){var n=e[i][1][r];return o(n||r)},p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i fetch - this._songPtr = 0 - this._bufferPtr = 0 - this._array = new Int16Array(BUFFER_SIZE * 2) - this._currentUrlOrBuf = null // currently loading url or buf - this._interval = null + this._ready = false; + this._playing = false; + this._pendingFetches = {}; // instrument -> fetch + this._songPtr = 0; + this._bufferPtr = 0; + this._array = new Int16Array(BUFFER_SIZE * 2); + this._currentUrlOrBuf = null; // currently loading url or buf + this._interval = null; - this._startInterval = this._startInterval.bind(this) - this._stopInterval = this._stopInterval.bind(this) + this._startInterval = this._startInterval.bind(this); + this._stopInterval = this._stopInterval.bind(this); // If the Timidity constructor was not invoked inside a user-initiated event // handler, then the AudioContext will be suspended. See: // https://developers.google.com/web/updates/2017/09/autoplay-policy-changes - this._audioContext = new AudioContext() + this._audioContext = audioContext || new AudioContext(); // Start the 'onaudioprocess' events flowing this._node = this._audioContext.createScriptProcessor( BUFFER_SIZE, 0, NUM_CHANNELS - ) - this._onAudioProcess = this._onAudioProcess.bind(this) - this._node.addEventListener('audioprocess', this._onAudioProcess) - this._node.connect(this._audioContext.destination) + ); + this._onAudioProcess = this._onAudioProcess.bind(this); + this._node.addEventListener("audioprocess", this._onAudioProcess); + this._node.connect(destination || this._audioContext.destination); this._lib = LibTimidity({ locateFile: file => new URL(file, this._baseUrl).href, - onRuntimeInitialized: () => this._onLibReady() - }) + onRuntimeInitialized: () => this._onLibReady(), + }); } - _onLibReady () { - this._lib.FS.writeFile('/timidity.cfg', TIMIDITY_CFG) + _onLibReady() { + this._lib.FS.writeFile("/timidity.cfg", TIMIDITY_CFG); - const result = this._lib._mid_init('/timidity.cfg') + const result = this._lib._mid_init("/timidity.cfg"); if (result !== 0) { - return this._destroy(new Error('Failed to initialize libtimidity')) + return this._destroy(new Error("Failed to initialize libtimidity")); } - this._bufferPtr = this._lib._malloc(BUFFER_SIZE * BYTES_PER_SAMPLE) + this._bufferPtr = this._lib._malloc(BUFFER_SIZE * BYTES_PER_SAMPLE); - debugVerbose('Initialized libtimidity') - this._ready = true - this.emit('_ready') + debugVerbose("Initialized libtimidity"); + this._ready = true; + this.emit("_ready"); } - async load (urlOrBuf) { - debug('load %o', urlOrBuf) - if (this.destroyed) throw new Error('load() called after destroy()') + async load(urlOrBuf) { + debug("load %o", urlOrBuf); + if (this.destroyed) throw new Error("load() called after destroy()"); // If the Timidity constructor was not invoked inside a user-initiated event // handler, then the AudioContext will be suspended. Attempt to resume it. - this._audioContext.resume() + this._audioContext.resume(); // If a song already exists, destroy it before starting a new one - if (this._songPtr) this._destroySong() + if (this._songPtr) this._destroySong(); - this.emit('unstarted') - this._stopInterval() + this.emit("unstarted"); + this._stopInterval(); - if (!this._ready) return this.once('_ready', () => this.load(urlOrBuf)) + if (!this._ready) return this.once("_ready", () => this.load(urlOrBuf)); - this.emit('buffering') + this.emit("buffering"); // Save the url or buf to load. Allows detection of when a new interleaved // load() starts so we can abort this load. - this._currentUrlOrBuf = urlOrBuf + this._currentUrlOrBuf = urlOrBuf; - let midiBuf - if (typeof urlOrBuf === 'string') { - midiBuf = await this._fetch(new URL(urlOrBuf, this._baseUrl)) + let midiBuf; + if (typeof urlOrBuf === "string") { + midiBuf = await this._fetch(new URL(urlOrBuf, this._baseUrl)); // If another load() started while awaiting, abort this load - if (this._currentUrlOrBuf !== urlOrBuf) return + if (this._currentUrlOrBuf !== urlOrBuf) return; } else if (urlOrBuf instanceof Uint8Array) { - midiBuf = urlOrBuf + midiBuf = urlOrBuf; } else { - throw new Error('load() expects a `string` or `Uint8Array` argument') + throw new Error("load() expects a `string` or `Uint8Array` argument"); } - let songPtr = this._loadSong(midiBuf) + let songPtr = this._loadSong(midiBuf); // Are we missing instrument files? - let missingCount = this._lib._mid_get_load_request_count(songPtr) + let missingCount = this._lib._mid_get_load_request_count(songPtr); if (missingCount > 0) { - let missingInstruments = this._getMissingInstruments(songPtr, missingCount) - debugVerbose('Fetching instruments: %o', missingInstruments) + let missingInstruments = this._getMissingInstruments( + songPtr, + missingCount + ); + debugVerbose("Fetching instruments: %o", missingInstruments); // Wait for all instruments to load await Promise.all( missingInstruments.map(instrument => this._fetchInstrument(instrument)) - ) + ); // If another load() started while awaiting, abort this load - if (this._currentUrlOrBuf !== urlOrBuf) return + if (this._currentUrlOrBuf !== urlOrBuf) return; // Retry the song load, now that instruments have been loaded - this._lib._mid_song_free(songPtr) - songPtr = this._loadSong(midiBuf) + this._lib._mid_song_free(songPtr); + songPtr = this._loadSong(midiBuf); // Are we STILL missing instrument files? Then our General MIDI soundset // is probably missing instrument files. - missingCount = this._lib._mid_get_load_request_count(songPtr) + missingCount = this._lib._mid_get_load_request_count(songPtr); // Print out missing instrument names if (missingCount > 0) { - missingInstruments = this._getMissingInstruments(songPtr, missingCount) - debug('Playing with missing instruments: %o', missingInstruments) + missingInstruments = this._getMissingInstruments(songPtr, missingCount); + debug("Playing with missing instruments: %o", missingInstruments); } } - this._songPtr = songPtr - this._lib._mid_song_start(this._songPtr) - debugVerbose('Song and instruments are loaded') + this._songPtr = songPtr; + this._lib._mid_song_start(this._songPtr); + debugVerbose("Song and instruments are loaded"); } - _getMissingInstruments (songPtr, missingCount) { - const missingInstruments = [] + _getMissingInstruments(songPtr, missingCount) { + const missingInstruments = []; for (let i = 0; i < missingCount; i++) { - const instrumentPtr = this._lib._mid_get_load_request(songPtr, i) - const instrument = this._lib.UTF8ToString(instrumentPtr) - missingInstruments.push(instrument) + const instrumentPtr = this._lib._mid_get_load_request(songPtr, i); + const instrument = this._lib.UTF8ToString(instrumentPtr); + missingInstruments.push(instrument); } - return missingInstruments + return missingInstruments; } - _loadSong (midiBuf) { + _loadSong(midiBuf) { const optsPtr = this._lib._mid_alloc_options( SAMPLE_RATE, AUDIO_FORMAT, NUM_CHANNELS, BUFFER_SIZE - ) + ); // Copy the MIDI buffer into the heap - const midiBufPtr = this._lib._malloc(midiBuf.byteLength) - this._lib.HEAPU8.set(midiBuf, midiBufPtr) + const midiBufPtr = this._lib._malloc(midiBuf.byteLength); + this._lib.HEAPU8.set(midiBuf, midiBufPtr); // Create a stream - const iStreamPtr = this._lib._mid_istream_open_mem(midiBufPtr, midiBuf.byteLength) + const iStreamPtr = this._lib._mid_istream_open_mem( + midiBufPtr, + midiBuf.byteLength + ); // Load the song - const songPtr = this._lib._mid_song_load(iStreamPtr, optsPtr) + const songPtr = this._lib._mid_song_load(iStreamPtr, optsPtr); // Free resources no longer needed - this._lib._mid_istream_close(iStreamPtr) - this._lib._free(optsPtr) - this._lib._free(midiBufPtr) + this._lib._mid_istream_close(iStreamPtr); + this._lib._free(optsPtr); + this._lib._free(midiBufPtr); if (songPtr === 0) { - return this._destroy(new Error('Failed to load MIDI file')) + return this._destroy(new Error("Failed to load MIDI file")); } - return songPtr + return songPtr; } - async _fetchInstrument (instrument) { + async _fetchInstrument(instrument) { if (this._pendingFetches[instrument]) { // If this instrument is already in the process of being fetched, return // the existing promise to prevent duplicate fetches. - return this._pendingFetches[instrument] + return this._pendingFetches[instrument]; } - const url = new URL(instrument, this._baseUrl) - const bufPromise = this._fetch(url) - this._pendingFetches[instrument] = bufPromise + const url = new URL(instrument, this._baseUrl); + const bufPromise = this._fetch(url); + this._pendingFetches[instrument] = bufPromise; - const buf = await bufPromise - this._writeInstrumentFile(instrument, buf) + const buf = await bufPromise; + this._writeInstrumentFile(instrument, buf); - delete this._pendingFetches[instrument] + delete this._pendingFetches[instrument]; - return buf + return buf; } - _writeInstrumentFile (instrument, buf) { + _writeInstrumentFile(instrument, buf) { const folderPath = instrument - .split('/') + .split("/") .slice(0, -1) // remove basename - .join('/') - this._mkdirp(folderPath) - this._lib.FS.writeFile(instrument, buf, { encoding: 'binary' }) + .join("/"); + this._mkdirp(folderPath); + this._lib.FS.writeFile(instrument, buf, { encoding: "binary" }); } - _mkdirp (folderPath) { - const pathParts = folderPath.split('/') - let dirPath = '/' + _mkdirp(folderPath) { + const pathParts = folderPath.split("/"); + let dirPath = "/"; for (let i = 0; i < pathParts.length; i++) { - const curPart = pathParts[i] + const curPart = pathParts[i]; try { - this._lib.FS.mkdir(`${dirPath}${curPart}`) + this._lib.FS.mkdir(`${dirPath}${curPart}`); } catch (err) {} - dirPath += `${curPart}/` + dirPath += `${curPart}/`; } } - async _fetch (url) { + async _fetch(url) { const opts = { - mode: 'cors', - credentials: 'same-origin' - } - const response = await window.fetch(url, opts) - if (response.status !== 200) throw new Error(`Could not load ${url}`) + mode: "cors", + credentials: "same-origin", + }; + const response = await window.fetch(url, opts); + if (response.status !== 200) throw new Error(`Could not load ${url}`); - const arrayBuffer = await response.arrayBuffer() - const buf = new Uint8Array(arrayBuffer) - return buf + const arrayBuffer = await response.arrayBuffer(); + const buf = new Uint8Array(arrayBuffer); + return buf; } - play () { - debug('play') - if (this.destroyed) throw new Error('play() called after destroy()') + play() { + debug("play"); + if (this.destroyed) throw new Error("play() called after destroy()"); // If the Timidity constructor was not invoked inside a user-initiated event // handler, then the AudioContext will be suspended. Attempt to resume it. - this._audioContext.resume() + this._audioContext.resume(); - this._playing = true + this._playing = true; if (this._ready && !this._currentUrlOrBuf) { - this.emit('playing') - this._startInterval() + this.emit("playing"); + this._startInterval(); } } - _onAudioProcess (event) { - const sampleCount = (this._songPtr && this._playing) - ? this._readMidiData() - : 0 + _onAudioProcess(event) { + const sampleCount = + this._songPtr && this._playing ? this._readMidiData() : 0; if (sampleCount > 0 && this._currentUrlOrBuf) { - this._currentUrlOrBuf = null - this.emit('playing') - this._startInterval() + this._currentUrlOrBuf = null; + this.emit("playing"); + this._startInterval(); } - const output0 = event.outputBuffer.getChannelData(0) - const output1 = event.outputBuffer.getChannelData(1) + const output0 = event.outputBuffer.getChannelData(0); + const output1 = event.outputBuffer.getChannelData(1); for (let i = 0; i < sampleCount; i++) { - output0[i] = this._array[i * 2] / 0x7FFF - output1[i] = this._array[i * 2 + 1] / 0x7FFF + output0[i] = this._array[i * 2] / 0x7fff; + output1[i] = this._array[i * 2 + 1] / 0x7fff; } for (let i = sampleCount; i < BUFFER_SIZE; i++) { - output0[i] = 0 - output1[i] = 0 + output0[i] = 0; + output1[i] = 0; } if (this._songPtr && this._playing && sampleCount === 0) { // Reached the end of the file - this.seek(0) - this.pause() - this._lib._mid_song_start(this._songPtr) - this.emit('ended') + this.seek(0); + this.pause(); + this._lib._mid_song_start(this._songPtr); + this.emit("ended"); } } - _readMidiData () { + _readMidiData() { const byteCount = this._lib._mid_song_read_wave( this._songPtr, this._bufferPtr, BUFFER_SIZE * BYTES_PER_SAMPLE - ) - const sampleCount = byteCount / BYTES_PER_SAMPLE + ); + const sampleCount = byteCount / BYTES_PER_SAMPLE; // Was anything output? If not, don't bother copying anything if (sampleCount === 0) { - return 0 + return 0; } this._array.set( - this._lib.HEAP16.subarray(this._bufferPtr / 2, (this._bufferPtr + byteCount) / 2) - ) + this._lib.HEAP16.subarray( + this._bufferPtr / 2, + (this._bufferPtr + byteCount) / 2 + ) + ); - return sampleCount + return sampleCount; } - pause () { - debug('pause') - if (this.destroyed) throw new Error('pause() called after destroy()') + pause() { + debug("pause"); + if (this.destroyed) throw new Error("pause() called after destroy()"); - this._playing = false - this._stopInterval() - this.emit('paused') + this._playing = false; + this._stopInterval(); + this.emit("paused"); } - seek (time) { - debug('seek %d', time) - if (this.destroyed) throw new Error('seek() called after destroy()') - if (!this._songPtr) return // ignore seek if there is no song loaded yet + seek(time) { + debug("seek %d", time); + if (this.destroyed) throw new Error("seek() called after destroy()"); + if (!this._songPtr) return; // ignore seek if there is no song loaded yet - const timeMs = Math.floor(time * 1000) - this._lib._mid_song_seek(this._songPtr, timeMs) - this._onTimeupdate() + const timeMs = Math.floor(time * 1000); + this._lib._mid_song_seek(this._songPtr, timeMs); + this._onTimeupdate(); } - get currentTime () { - if (this.destroyed || !this._songPtr) return 0 - return this._lib._mid_song_get_time(this._songPtr) / 1000 + get currentTime() { + if (this.destroyed || !this._songPtr) return 0; + return this._lib._mid_song_get_time(this._songPtr) / 1000; } - get duration () { - if (this.destroyed || !this._songPtr) return 1 - return this._lib._mid_song_get_total_time(this._songPtr) / 1000 + get duration() { + if (this.destroyed || !this._songPtr) return 1; + return this._lib._mid_song_get_total_time(this._songPtr) / 1000; } /** * This event fires when the time indicated by the `currentTime` property * has been updated. */ - _onTimeupdate () { - this.emit('timeupdate', this.currentTime) + _onTimeupdate() { + this.emit("timeupdate", this.currentTime); } - _startInterval () { - this._onTimeupdate() - this._interval = setInterval(() => this._onTimeupdate(), 1000) + _startInterval() { + this._onTimeupdate(); + this._interval = setInterval(() => this._onTimeupdate(), 1000); } - _stopInterval () { - this._onTimeupdate() - clearInterval(this._interval) - this._interval = null + _stopInterval() { + this._onTimeupdate(); + clearInterval(this._interval); + this._interval = null; } - destroy () { - debug('destroy') - if (this.destroyed) throw new Error('destroy() called after destroy()') - this._destroy() + destroy() { + debug("destroy"); + if (this.destroyed) throw new Error("destroy() called after destroy()"); + this._destroy(); } - _destroy (err) { - if (this.destroyed) return - this.destroyed = true + _destroy(err) { + if (this.destroyed) return; + this.destroyed = true; - this._stopInterval() + this._stopInterval(); - this._array = null + this._array = null; if (this._songPtr) { - this._destroySong() + this._destroySong(); } if (this._bufferPtr) { - this._lib._free(this._bufferPtr) - this._bufferPtr = 0 + this._lib._free(this._bufferPtr); + this._bufferPtr = 0; } if (this._node) { - this._node.disconnect() - this._node.removeEventListener('audioprocess', this._onAudioProcess) + this._node.disconnect(); + this._node.removeEventListener("audioprocess", this._onAudioProcess); } if (this._audioContext) { - this._audioContext.close() + this._audioContext.close(); } - if (err) this.emit('error', err) - debug('destroyed (err %o)', err) + if (err) this.emit("error", err); + debug("destroyed (err %o)", err); } - _destroySong () { - this._lib._mid_song_free(this._songPtr) - this._songPtr = 0 + _destroySong() { + this._lib._mid_song_free(this._songPtr); + this._songPtr = 0; } } -module.exports = Timidity +module.exports = Timidity; },{"./libtimidity":2,"debug":3,"events":5}],2:[function(require,module,exports){ diff --git a/demo/timidity/index.js b/demo/timidity/index.js index fc7fd941..204d047f 100644 --- a/demo/timidity/index.js +++ b/demo/timidity/index.js @@ -1,401 +1,410 @@ -const Debug = require('debug') -const EventEmitter = require('events').EventEmitter -const fs = require('fs') -const LibTimidity = require('./libtimidity') +const Debug = require("debug"); +const EventEmitter = require("events").EventEmitter; +const fs = require("fs"); +const LibTimidity = require("./libtimidity"); -const debug = Debug('timidity') -const debugVerbose = Debug('timidity:verbose') +const debug = Debug("timidity"); +const debugVerbose = Debug("timidity:verbose"); // Inlined at build time by 'brfs' browserify transform const TIMIDITY_CFG = fs.readFileSync( - __dirname + '/freepats.cfg', // eslint-disable-line no-path-concat - 'utf8' -) + __dirname + "/freepats.cfg", // eslint-disable-line no-path-concat + "utf8" +); -const SAMPLE_RATE = 44100 -const AUDIO_FORMAT = 0x8010 // format of the rendered audio 's16' -const NUM_CHANNELS = 2 // stereo (2 channels) -const BYTES_PER_SAMPLE = 2 * NUM_CHANNELS -const BUFFER_SIZE = 16384 // buffer size for each render() call +const SAMPLE_RATE = 44100; +const AUDIO_FORMAT = 0x8010; // format of the rendered audio 's16' +const NUM_CHANNELS = 2; // stereo (2 channels) +const BYTES_PER_SAMPLE = 2 * NUM_CHANNELS; +const BUFFER_SIZE = 16384; // buffer size for each render() call -const AudioContext = typeof window !== 'undefined' && - (window.AudioContext || window.webkitAudioContext) +const AudioContext = + typeof window !== "undefined" && + (window.AudioContext || window.webkitAudioContext); class Timidity extends EventEmitter { - constructor (baseUrl = '/') { - super() + constructor({ baseUrl = "/", audioContext, destination }) { + super(); - this.destroyed = false + this.destroyed = false; - if (!baseUrl.endsWith('/')) baseUrl += '/' - this._baseUrl = new URL(baseUrl, window.location.origin).href + if (!baseUrl.endsWith("/")) baseUrl += "/"; + this._baseUrl = new URL(baseUrl, window.location.origin).href; - this._ready = false - this._playing = false - this._pendingFetches = {} // instrument -> fetch - this._songPtr = 0 - this._bufferPtr = 0 - this._array = new Int16Array(BUFFER_SIZE * 2) - this._currentUrlOrBuf = null // currently loading url or buf - this._interval = null + this._ready = false; + this._playing = false; + this._pendingFetches = {}; // instrument -> fetch + this._songPtr = 0; + this._bufferPtr = 0; + this._array = new Int16Array(BUFFER_SIZE * 2); + this._currentUrlOrBuf = null; // currently loading url or buf + this._interval = null; - this._startInterval = this._startInterval.bind(this) - this._stopInterval = this._stopInterval.bind(this) + this._startInterval = this._startInterval.bind(this); + this._stopInterval = this._stopInterval.bind(this); // If the Timidity constructor was not invoked inside a user-initiated event // handler, then the AudioContext will be suspended. See: // https://developers.google.com/web/updates/2017/09/autoplay-policy-changes - this._audioContext = new AudioContext() + this._audioContext = audioContext || new AudioContext(); // Start the 'onaudioprocess' events flowing this._node = this._audioContext.createScriptProcessor( BUFFER_SIZE, 0, NUM_CHANNELS - ) - this._onAudioProcess = this._onAudioProcess.bind(this) - this._node.addEventListener('audioprocess', this._onAudioProcess) - this._node.connect(this._audioContext.destination) + ); + this._onAudioProcess = this._onAudioProcess.bind(this); + this._node.addEventListener("audioprocess", this._onAudioProcess); + this._node.connect(destination || this._audioContext.destination); this._lib = LibTimidity({ locateFile: file => new URL(file, this._baseUrl).href, - onRuntimeInitialized: () => this._onLibReady() - }) + onRuntimeInitialized: () => this._onLibReady(), + }); } - _onLibReady () { - this._lib.FS.writeFile('/timidity.cfg', TIMIDITY_CFG) + _onLibReady() { + this._lib.FS.writeFile("/timidity.cfg", TIMIDITY_CFG); - const result = this._lib._mid_init('/timidity.cfg') + const result = this._lib._mid_init("/timidity.cfg"); if (result !== 0) { - return this._destroy(new Error('Failed to initialize libtimidity')) + return this._destroy(new Error("Failed to initialize libtimidity")); } - this._bufferPtr = this._lib._malloc(BUFFER_SIZE * BYTES_PER_SAMPLE) + this._bufferPtr = this._lib._malloc(BUFFER_SIZE * BYTES_PER_SAMPLE); - debugVerbose('Initialized libtimidity') - this._ready = true - this.emit('_ready') + debugVerbose("Initialized libtimidity"); + this._ready = true; + this.emit("_ready"); } - async load (urlOrBuf) { - debug('load %o', urlOrBuf) - if (this.destroyed) throw new Error('load() called after destroy()') + async load(urlOrBuf) { + debug("load %o", urlOrBuf); + if (this.destroyed) throw new Error("load() called after destroy()"); // If the Timidity constructor was not invoked inside a user-initiated event // handler, then the AudioContext will be suspended. Attempt to resume it. - this._audioContext.resume() + this._audioContext.resume(); // If a song already exists, destroy it before starting a new one - if (this._songPtr) this._destroySong() + if (this._songPtr) this._destroySong(); - this.emit('unstarted') - this._stopInterval() + this.emit("unstarted"); + this._stopInterval(); - if (!this._ready) return this.once('_ready', () => this.load(urlOrBuf)) + if (!this._ready) return this.once("_ready", () => this.load(urlOrBuf)); - this.emit('buffering') + this.emit("buffering"); // Save the url or buf to load. Allows detection of when a new interleaved // load() starts so we can abort this load. - this._currentUrlOrBuf = urlOrBuf + this._currentUrlOrBuf = urlOrBuf; - let midiBuf - if (typeof urlOrBuf === 'string') { - midiBuf = await this._fetch(new URL(urlOrBuf, this._baseUrl)) + let midiBuf; + if (typeof urlOrBuf === "string") { + midiBuf = await this._fetch(new URL(urlOrBuf, this._baseUrl)); // If another load() started while awaiting, abort this load - if (this._currentUrlOrBuf !== urlOrBuf) return + if (this._currentUrlOrBuf !== urlOrBuf) return; } else if (urlOrBuf instanceof Uint8Array) { - midiBuf = urlOrBuf + midiBuf = urlOrBuf; } else { - throw new Error('load() expects a `string` or `Uint8Array` argument') + throw new Error("load() expects a `string` or `Uint8Array` argument"); } - let songPtr = this._loadSong(midiBuf) + let songPtr = this._loadSong(midiBuf); // Are we missing instrument files? - let missingCount = this._lib._mid_get_load_request_count(songPtr) + let missingCount = this._lib._mid_get_load_request_count(songPtr); if (missingCount > 0) { - let missingInstruments = this._getMissingInstruments(songPtr, missingCount) - debugVerbose('Fetching instruments: %o', missingInstruments) + let missingInstruments = this._getMissingInstruments( + songPtr, + missingCount + ); + debugVerbose("Fetching instruments: %o", missingInstruments); // Wait for all instruments to load await Promise.all( missingInstruments.map(instrument => this._fetchInstrument(instrument)) - ) + ); // If another load() started while awaiting, abort this load - if (this._currentUrlOrBuf !== urlOrBuf) return + if (this._currentUrlOrBuf !== urlOrBuf) return; // Retry the song load, now that instruments have been loaded - this._lib._mid_song_free(songPtr) - songPtr = this._loadSong(midiBuf) + this._lib._mid_song_free(songPtr); + songPtr = this._loadSong(midiBuf); // Are we STILL missing instrument files? Then our General MIDI soundset // is probably missing instrument files. - missingCount = this._lib._mid_get_load_request_count(songPtr) + missingCount = this._lib._mid_get_load_request_count(songPtr); // Print out missing instrument names if (missingCount > 0) { - missingInstruments = this._getMissingInstruments(songPtr, missingCount) - debug('Playing with missing instruments: %o', missingInstruments) + missingInstruments = this._getMissingInstruments(songPtr, missingCount); + debug("Playing with missing instruments: %o", missingInstruments); } } - this._songPtr = songPtr - this._lib._mid_song_start(this._songPtr) - debugVerbose('Song and instruments are loaded') + this._songPtr = songPtr; + this._lib._mid_song_start(this._songPtr); + debugVerbose("Song and instruments are loaded"); } - _getMissingInstruments (songPtr, missingCount) { - const missingInstruments = [] + _getMissingInstruments(songPtr, missingCount) { + const missingInstruments = []; for (let i = 0; i < missingCount; i++) { - const instrumentPtr = this._lib._mid_get_load_request(songPtr, i) - const instrument = this._lib.UTF8ToString(instrumentPtr) - missingInstruments.push(instrument) + const instrumentPtr = this._lib._mid_get_load_request(songPtr, i); + const instrument = this._lib.UTF8ToString(instrumentPtr); + missingInstruments.push(instrument); } - return missingInstruments + return missingInstruments; } - _loadSong (midiBuf) { + _loadSong(midiBuf) { const optsPtr = this._lib._mid_alloc_options( SAMPLE_RATE, AUDIO_FORMAT, NUM_CHANNELS, BUFFER_SIZE - ) + ); // Copy the MIDI buffer into the heap - const midiBufPtr = this._lib._malloc(midiBuf.byteLength) - this._lib.HEAPU8.set(midiBuf, midiBufPtr) + const midiBufPtr = this._lib._malloc(midiBuf.byteLength); + this._lib.HEAPU8.set(midiBuf, midiBufPtr); // Create a stream - const iStreamPtr = this._lib._mid_istream_open_mem(midiBufPtr, midiBuf.byteLength) + const iStreamPtr = this._lib._mid_istream_open_mem( + midiBufPtr, + midiBuf.byteLength + ); // Load the song - const songPtr = this._lib._mid_song_load(iStreamPtr, optsPtr) + const songPtr = this._lib._mid_song_load(iStreamPtr, optsPtr); // Free resources no longer needed - this._lib._mid_istream_close(iStreamPtr) - this._lib._free(optsPtr) - this._lib._free(midiBufPtr) + this._lib._mid_istream_close(iStreamPtr); + this._lib._free(optsPtr); + this._lib._free(midiBufPtr); if (songPtr === 0) { - return this._destroy(new Error('Failed to load MIDI file')) + return this._destroy(new Error("Failed to load MIDI file")); } - return songPtr + return songPtr; } - async _fetchInstrument (instrument) { + async _fetchInstrument(instrument) { if (this._pendingFetches[instrument]) { // If this instrument is already in the process of being fetched, return // the existing promise to prevent duplicate fetches. - return this._pendingFetches[instrument] + return this._pendingFetches[instrument]; } - const url = new URL(instrument, this._baseUrl) - const bufPromise = this._fetch(url) - this._pendingFetches[instrument] = bufPromise + const url = new URL(instrument, this._baseUrl); + const bufPromise = this._fetch(url); + this._pendingFetches[instrument] = bufPromise; - const buf = await bufPromise - this._writeInstrumentFile(instrument, buf) + const buf = await bufPromise; + this._writeInstrumentFile(instrument, buf); - delete this._pendingFetches[instrument] + delete this._pendingFetches[instrument]; - return buf + return buf; } - _writeInstrumentFile (instrument, buf) { + _writeInstrumentFile(instrument, buf) { const folderPath = instrument - .split('/') + .split("/") .slice(0, -1) // remove basename - .join('/') - this._mkdirp(folderPath) - this._lib.FS.writeFile(instrument, buf, { encoding: 'binary' }) + .join("/"); + this._mkdirp(folderPath); + this._lib.FS.writeFile(instrument, buf, { encoding: "binary" }); } - _mkdirp (folderPath) { - const pathParts = folderPath.split('/') - let dirPath = '/' + _mkdirp(folderPath) { + const pathParts = folderPath.split("/"); + let dirPath = "/"; for (let i = 0; i < pathParts.length; i++) { - const curPart = pathParts[i] + const curPart = pathParts[i]; try { - this._lib.FS.mkdir(`${dirPath}${curPart}`) + this._lib.FS.mkdir(`${dirPath}${curPart}`); } catch (err) {} - dirPath += `${curPart}/` + dirPath += `${curPart}/`; } } - async _fetch (url) { + async _fetch(url) { const opts = { - mode: 'cors', - credentials: 'same-origin' - } - const response = await window.fetch(url, opts) - if (response.status !== 200) throw new Error(`Could not load ${url}`) + mode: "cors", + credentials: "same-origin", + }; + const response = await window.fetch(url, opts); + if (response.status !== 200) throw new Error(`Could not load ${url}`); - const arrayBuffer = await response.arrayBuffer() - const buf = new Uint8Array(arrayBuffer) - return buf + const arrayBuffer = await response.arrayBuffer(); + const buf = new Uint8Array(arrayBuffer); + return buf; } - play () { - debug('play') - if (this.destroyed) throw new Error('play() called after destroy()') + play() { + debug("play"); + if (this.destroyed) throw new Error("play() called after destroy()"); // If the Timidity constructor was not invoked inside a user-initiated event // handler, then the AudioContext will be suspended. Attempt to resume it. - this._audioContext.resume() + this._audioContext.resume(); - this._playing = true + this._playing = true; if (this._ready && !this._currentUrlOrBuf) { - this.emit('playing') - this._startInterval() + this.emit("playing"); + this._startInterval(); } } - _onAudioProcess (event) { - const sampleCount = (this._songPtr && this._playing) - ? this._readMidiData() - : 0 + _onAudioProcess(event) { + const sampleCount = + this._songPtr && this._playing ? this._readMidiData() : 0; if (sampleCount > 0 && this._currentUrlOrBuf) { - this._currentUrlOrBuf = null - this.emit('playing') - this._startInterval() + this._currentUrlOrBuf = null; + this.emit("playing"); + this._startInterval(); } - const output0 = event.outputBuffer.getChannelData(0) - const output1 = event.outputBuffer.getChannelData(1) + const output0 = event.outputBuffer.getChannelData(0); + const output1 = event.outputBuffer.getChannelData(1); for (let i = 0; i < sampleCount; i++) { - output0[i] = this._array[i * 2] / 0x7FFF - output1[i] = this._array[i * 2 + 1] / 0x7FFF + output0[i] = this._array[i * 2] / 0x7fff; + output1[i] = this._array[i * 2 + 1] / 0x7fff; } for (let i = sampleCount; i < BUFFER_SIZE; i++) { - output0[i] = 0 - output1[i] = 0 + output0[i] = 0; + output1[i] = 0; } if (this._songPtr && this._playing && sampleCount === 0) { // Reached the end of the file - this.seek(0) - this.pause() - this._lib._mid_song_start(this._songPtr) - this.emit('ended') + this.seek(0); + this.pause(); + this._lib._mid_song_start(this._songPtr); + this.emit("ended"); } } - _readMidiData () { + _readMidiData() { const byteCount = this._lib._mid_song_read_wave( this._songPtr, this._bufferPtr, BUFFER_SIZE * BYTES_PER_SAMPLE - ) - const sampleCount = byteCount / BYTES_PER_SAMPLE + ); + const sampleCount = byteCount / BYTES_PER_SAMPLE; // Was anything output? If not, don't bother copying anything if (sampleCount === 0) { - return 0 + return 0; } this._array.set( - this._lib.HEAP16.subarray(this._bufferPtr / 2, (this._bufferPtr + byteCount) / 2) - ) + this._lib.HEAP16.subarray( + this._bufferPtr / 2, + (this._bufferPtr + byteCount) / 2 + ) + ); - return sampleCount + return sampleCount; } - pause () { - debug('pause') - if (this.destroyed) throw new Error('pause() called after destroy()') + pause() { + debug("pause"); + if (this.destroyed) throw new Error("pause() called after destroy()"); - this._playing = false - this._stopInterval() - this.emit('paused') + this._playing = false; + this._stopInterval(); + this.emit("paused"); } - seek (time) { - debug('seek %d', time) - if (this.destroyed) throw new Error('seek() called after destroy()') - if (!this._songPtr) return // ignore seek if there is no song loaded yet + seek(time) { + debug("seek %d", time); + if (this.destroyed) throw new Error("seek() called after destroy()"); + if (!this._songPtr) return; // ignore seek if there is no song loaded yet - const timeMs = Math.floor(time * 1000) - this._lib._mid_song_seek(this._songPtr, timeMs) - this._onTimeupdate() + const timeMs = Math.floor(time * 1000); + this._lib._mid_song_seek(this._songPtr, timeMs); + this._onTimeupdate(); } - get currentTime () { - if (this.destroyed || !this._songPtr) return 0 - return this._lib._mid_song_get_time(this._songPtr) / 1000 + get currentTime() { + if (this.destroyed || !this._songPtr) return 0; + return this._lib._mid_song_get_time(this._songPtr) / 1000; } - get duration () { - if (this.destroyed || !this._songPtr) return 1 - return this._lib._mid_song_get_total_time(this._songPtr) / 1000 + get duration() { + if (this.destroyed || !this._songPtr) return 1; + return this._lib._mid_song_get_total_time(this._songPtr) / 1000; } /** * This event fires when the time indicated by the `currentTime` property * has been updated. */ - _onTimeupdate () { - this.emit('timeupdate', this.currentTime) + _onTimeupdate() { + this.emit("timeupdate", this.currentTime); } - _startInterval () { - this._onTimeupdate() - this._interval = setInterval(() => this._onTimeupdate(), 1000) + _startInterval() { + this._onTimeupdate(); + this._interval = setInterval(() => this._onTimeupdate(), 1000); } - _stopInterval () { - this._onTimeupdate() - clearInterval(this._interval) - this._interval = null + _stopInterval() { + this._onTimeupdate(); + clearInterval(this._interval); + this._interval = null; } - destroy () { - debug('destroy') - if (this.destroyed) throw new Error('destroy() called after destroy()') - this._destroy() + destroy() { + debug("destroy"); + if (this.destroyed) throw new Error("destroy() called after destroy()"); + this._destroy(); } - _destroy (err) { - if (this.destroyed) return - this.destroyed = true + _destroy(err) { + if (this.destroyed) return; + this.destroyed = true; - this._stopInterval() + this._stopInterval(); - this._array = null + this._array = null; if (this._songPtr) { - this._destroySong() + this._destroySong(); } if (this._bufferPtr) { - this._lib._free(this._bufferPtr) - this._bufferPtr = 0 + this._lib._free(this._bufferPtr); + this._bufferPtr = 0; } if (this._node) { - this._node.disconnect() - this._node.removeEventListener('audioprocess', this._onAudioProcess) + this._node.disconnect(); + this._node.removeEventListener("audioprocess", this._onAudioProcess); } if (this._audioContext) { - this._audioContext.close() + this._audioContext.close(); } - if (err) this.emit('error', err) - debug('destroyed (err %o)', err) + if (err) this.emit("error", err); + debug("destroyed (err %o)", err); } - _destroySong () { - this._lib._mid_song_free(this._songPtr) - this._songPtr = 0 + _destroySong() { + this._lib._mid_song_free(this._songPtr); + this._songPtr = 0; } } -module.exports = Timidity +module.exports = Timidity; diff --git a/js/media/elementSource.ts b/js/media/elementSource.ts index 6212bac1..8fe59be3 100644 --- a/js/media/elementSource.ts +++ b/js/media/elementSource.ts @@ -1,5 +1,4 @@ import Emitter from "../emitter"; -import { clamp } from "../utils"; import { MEDIA_STATUS } from "../constants"; import { MediaStatus } from "../types"; import Timidity from "../../demo/timidity/bundle.js"; @@ -7,9 +6,7 @@ import Timidity from "../../demo/timidity/bundle.js"; export default class ElementSource { _emitter: Emitter; _context: AudioContext; - _source: AudioNode; _destination: AudioNode; - _audio: HTMLAudioElement; _stalled: boolean; _status: MediaStatus; _player: Timidity; @@ -22,12 +19,14 @@ export default class ElementSource { this._emitter = new Emitter(); this._context = context; this._destination = destination; - this._audio = document.createElement("audio"); - this._audio.crossOrigin = "anonymous"; this._stalled = false; this._status = MEDIA_STATUS.STOPPED; - this._player = new Timidity("/demo/timidity/"); + this._player = new Timidity({ + baseUrl: "/demo/timidity/", + audioContext: context, + destination, + }); // TODO: #leak this._player.on("unstarted", () => { @@ -45,44 +44,17 @@ export default class ElementSource { }); // TODO: #leak - this._audio.addEventListener("ended", () => { + this._player.on("ended", () => { this._emitter.trigger("ended"); this._setStatus(MEDIA_STATUS.STOPPED); }); // TODO: #leak - this._audio.addEventListener("error", e => { - switch (this._audio.error!.code) { - case 1: - // The fetching of the associated resource was aborted by the user's request. - console.error("MEDIA_ERR_ABORTED", e); - break; - case 2: - console.error("MEDIA_ERR_NETWORK", e); - // Some kind of network error occurred which prevented the media from being successfully fetched, despite having previously been available. - break; - case 3: - // Despite having previously been determined to be usable, an error occurred while trying to decode the media resource, resulting in an error. - - // There is a bug in Chrome where improperly terminated mp3s can cuase this error. - // https://bugs.chromium.org/p/chromium/issues/detail?id=794782 - // Related: Commit f44e826c83c74fef04c2c448af30cfb353b28312 - console.error("PIPELINE_ERROR_DECODE", e); - break; - case 4: - console.error("MEDIA_ERR_SRC_NOT_SUPPORTED", e); - // The associated resource or media provider object (such as a MediaStream) has been found to be unsuitable. - break; - } - // Rather than just geting stuck in this error state, we can just pretend this is - // the end of the track. - + this._player.on("error", e => { + console.error("Timidity error", e); this._emitter.trigger("ended"); this._setStatus(MEDIA_STATUS.STOPPED); }); - - this._source = this._context.createMediaElementSource(this._audio); - this._source.connect(destination); } _setStalled(stalled: boolean) { @@ -91,7 +63,7 @@ export default class ElementSource { } disconnect() { - this._source.disconnect(); + this._player._node.disconnect(); } // Async for now, for compatibility with BufferAudioSource @@ -144,8 +116,8 @@ export default class ElementSource { } dispose() { - // TODO: Dispose subscriptions to this.audio this.stop(); this._emitter.dispose(); + this._player.destroy(); } }