UNPKG

@soapbox.pub/wasmboy

Version:

Soapbox fork of Wasmboy.

1,811 lines (1,454 loc) 211 kB
// API For adding plugins for WasmBoy // Should follow the Rollup Plugin API // https://rollupjs.org/guide/en#plugins // Plugins have the following supported hooks // And properties const WASMBOY_PLUGIN = { name: 'wasmboy-plugin REQUIRED', graphics: rgbaArray => {}, // Returns undefined. Edit object in place audio: (audioContext, headAudioNode, channelId) => {}, // Return AudioNode, which will be connected to the destination node eventually. saveState: saveStateObject => {}, // Returns undefined. Edit object in place. canvas: (canvasElement, canvasContext, canvasImageData) => {}, // Returns undefined. Edit object in place. breakpoint: () => {}, ready: () => {}, play: () => {}, pause: () => {}, loadedAndStarted: () => {} }; class WasmBoyPluginsService { constructor() { this.plugins = {}; this.pluginIdCounter = 0; } addPlugin(pluginObject) { // Verify the plugin if (!pluginObject && typeof pluginObject !== 'object') { throw new Error('Invalid Plugin Object'); } if (!pluginObject.name) { throw new Error('Added plugin must have a "name" property'); } // Add the plugin to our plugin container const id = this.pluginIdCounter; this.plugins[this.pluginIdCounter] = pluginObject; this.pluginIdCounter++; // Return a function to remove the plugin return () => { this.removePlugin(id); }; } removePlugin(id) { delete this.plugins[id]; } runHook(hookConfig) { if (!WASMBOY_PLUGIN[hookConfig.key] || typeof WASMBOY_PLUGIN[hookConfig.key] !== 'function') { throw new Error('No such hook as ' + hookConfig.key); } Object.keys(this.plugins).forEach(pluginKey => { const plugin = this.plugins[pluginKey]; if (plugin[hookConfig.key]) { let hookResponse = undefined; try { hookResponse = plugin[hookConfig.key].apply(null, hookConfig.params); } catch (e) { console.error(`There was an error running the '${hookConfig.key}' hook, on the ${plugin.name} plugin.`); console.error(e); } if (hookConfig.callback) { hookConfig.callback(hookResponse); } } }); } } const WasmBoyPlugins = new WasmBoyPluginsService(); // Some shared constants by the graphics lib and worker const GAMEBOY_CAMERA_WIDTH = 160; const GAMEBOY_CAMERA_HEIGHT = 144; const WORKER_MESSAGE_TYPE = { CONNECT: 'CONNECT', INSTANTIATE_WASM: 'INSTANTIATE_WASM', CLEAR_MEMORY: 'CLEAR_MEMORY', CLEAR_MEMORY_DONE: 'CLEAR_MEMORY_DONE', GET_MEMORY: 'GET_MEMORY', SET_MEMORY: 'SET_MEMORY', SET_MEMORY_DONE: 'SET_MEMORY_DONE', GET_CONSTANTS: 'GET_CONSTANTS', GET_CONSTANTS_DONE: 'GET_CONSTANTS_DONE', CONFIG: 'CONFIG', RESET_AUDIO_QUEUE: 'RESET_AUDIO_QUEUE', PLAY: 'PLAY', BREAKPOINT: 'BREAKPOINT', PAUSE: 'PAUSE', UPDATED: 'UPDATED', CRASHED: 'CRASHED', SET_JOYPAD_STATE: 'SET_JOYPAD_STATE', AUDIO_LATENCY: 'AUDIO_LATENCY', RUN_WASM_EXPORT: 'RUN_WASM_EXPORT', GET_WASM_MEMORY_SECTION: 'GET_WASM_MEMORY_SECTION', GET_WASM_CONSTANT: 'GET_WASM_CONSTANT', FORCE_OUTPUT_FRAME: 'FORCE_OUTPUT_FRAME', SET_SPEED: 'SET_SPEED', IS_GBC: 'IS_GBC' }; const WORKER_ID = { LIB: 'LIB', GRAPHICS: 'GRAPHICS', MEMORY: 'MEMORY', CONTROLLER: 'CONTROLLER', AUDIO: 'AUDIO' }; const MEMORY_TYPE = { BOOT_ROM: 'BOOT_ROM', CARTRIDGE_RAM: 'CARTRIDGE_RAM', CARTRIDGE_ROM: 'CARTRIDGE_ROM', CARTRIDGE_HEADER: 'CARTRIDGE_HEADER', GAMEBOY_MEMORY: 'GAMEBOY_MEMORY', PALETTE_MEMORY: 'PALETTE_MEMORY', INTERNAL_STATE: 'INTERNAL_STATE' }; function getEventData(event) { if (event.data) { return event.data; } return event; } // Handles rendering graphics using the HTML5 Canvas // https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API/Tutorial/Optimizing_canvas class WasmBoyGraphicsService { constructor() { this.worker = undefined; this.updateGraphicsCallback = undefined; this.frameQueue = undefined; this.frameQueueRenderPromise = undefined; this.canvasElement = undefined; this.canvasContext = undefined; this.canvasImageData = undefined; this.imageDataArray = undefined; this.imageDataArrayChanged = false; } initialize(canvasElement, updateGraphicsCallback) { this.updateGraphicsCallback = updateGraphicsCallback; // Initialiuze our cached wasm constants // WASMBOY_CURRENT_FRAME_OUTPUT_LOCATION = this.wasmInstance.exports.frameInProgressGRAPHICS_OUTPUT_LOCATION.valueOf(); // Reset our frame queue and render promises this.frameQueue = []; const initializeTask = async () => { // Prepare our canvas this.canvasElement = canvasElement; this.canvasContext = this.canvasElement.getContext('2d'); this.canvasElement.width = GAMEBOY_CAMERA_WIDTH; this.canvasElement.height = GAMEBOY_CAMERA_HEIGHT; this.canvasImageData = this.canvasContext.createImageData(this.canvasElement.width, this.canvasElement.height); // Add some css for smooth 8-bit canvas scaling // https://stackoverflow.com/questions/7615009/disable-interpolation-when-scaling-a-canvas // https://caniuse.com/#feat=css-crisp-edges this.canvasElement.style = ` image-rendering: optimizeSpeed; image-rendering: -moz-crisp-edges; image-rendering: -webkit-optimize-contrast; image-rendering: -o-crisp-edges; image-rendering: pixelated; -ms-interpolation-mode: nearest-neighbor; `; // Fill the canvas with a blank screen // using client width since we are not requiring a width and height oin the canvas // https://developer.mozilla.org/en-US/docs/Web/API/Element/clientWidth // TODO: Mention respopnsive canvas scaling in the docs this.canvasContext.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height); // Doing set canvas here, as multiple sources can re-initialize the graphics // TODO: Move setCanvas out of initialize :p WasmBoyPlugins.runHook({ key: 'canvas', params: [this.canvasElement, this.canvasContext, this.canvasImageData], callback: response => { if (!response) { return; } if (response.canvasElement) { this.canvasElement = response.canvasElement; } if (response.canvasContext) { this.canvasContext = response.canvasContext; } if (response.canvasImageData) { this.canvasImageData = response.canvasImageData; } } }); // Finally make sure we set our constants for our worker if (this.worker) { await this.worker.postMessage({ type: WORKER_MESSAGE_TYPE.GET_CONSTANTS }); } }; return initializeTask(); } // Function to set our worker setWorker(worker) { this.worker = worker; this.worker.addMessageListener(event => { const eventData = getEventData(event); switch (eventData.message.type) { case WORKER_MESSAGE_TYPE.UPDATED: { this.imageDataArray = new Uint8ClampedArray(eventData.message.imageDataArrayBuffer); this.imageDataArrayChanged = true; return; } } }); } // Function to render a frame // Will add the frame to the frame queue to be rendered // Returns the promise from this.drawFrameQueue // Which resolves once all frames are rendered renderFrame() { // Check if we have new graphics to show if (!this.imageDataArrayChanged) { return; } this.imageDataArrayChanged = false; // Check for a callback for accessing image data if (this.updateGraphicsCallback) { this.updateGraphicsCallback(this.imageDataArray); } // Set the imageDataArray to our plugins WasmBoyPlugins.runHook({ key: 'graphics', params: [this.imageDataArray], callback: response => { if (response) { this.imageDataArray = response; } } }); // Add our new imageData this.canvasImageData.data.set(this.imageDataArray); this.canvasContext.clearRect(0, 0, this.canvasElement.width, this.canvasElement.height); this.canvasContext.putImageData(this.canvasImageData, 0, 0); } } const WasmBoyGraphics = new WasmBoyGraphicsService(); function index(buffer, opt) { opt = opt || {}; var numChannels = buffer.numberOfChannels; var sampleRate = buffer.sampleRate; var format = opt.float32 ? 3 : 1; var bitDepth = format === 3 ? 32 : 16; var result; if (numChannels === 2) { result = interleave(buffer.getChannelData(0), buffer.getChannelData(1)); } else { result = buffer.getChannelData(0); } return encodeWAV(result, format, sampleRate, numChannels, bitDepth) } function encodeWAV (samples, format, sampleRate, numChannels, bitDepth) { var bytesPerSample = bitDepth / 8; var blockAlign = numChannels * bytesPerSample; var buffer = new ArrayBuffer(44 + samples.length * bytesPerSample); var view = new DataView(buffer); /* RIFF identifier */ writeString(view, 0, 'RIFF'); /* RIFF chunk length */ view.setUint32(4, 36 + samples.length * bytesPerSample, true); /* RIFF type */ writeString(view, 8, 'WAVE'); /* format chunk identifier */ writeString(view, 12, 'fmt '); /* format chunk length */ view.setUint32(16, 16, true); /* sample format (raw) */ view.setUint16(20, format, true); /* channel count */ view.setUint16(22, numChannels, true); /* sample rate */ view.setUint32(24, sampleRate, true); /* byte rate (sample rate * block align) */ view.setUint32(28, sampleRate * blockAlign, true); /* block align (channel count * bytes per sample) */ view.setUint16(32, blockAlign, true); /* bits per sample */ view.setUint16(34, bitDepth, true); /* data chunk identifier */ writeString(view, 36, 'data'); /* data chunk length */ view.setUint32(40, samples.length * bytesPerSample, true); if (format === 1) { // Raw PCM floatTo16BitPCM(view, 44, samples); } else { writeFloat32(view, 44, samples); } return buffer } function interleave (inputL, inputR) { var length = inputL.length + inputR.length; var result = new Float32Array(length); var index = 0; var inputIndex = 0; while (index < length) { result[index++] = inputL[inputIndex]; result[index++] = inputR[inputIndex]; inputIndex++; } return result } function writeFloat32 (output, offset, input) { for (var i = 0; i < input.length; i++, offset += 4) { output.setFloat32(offset, input[i], true); } } function floatTo16BitPCM (output, offset, input) { for (var i = 0; i < input.length; i++, offset += 2) { var s = Math.max(-1, Math.min(1, input[i])); output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); } } function writeString (view, offset, string) { for (var i = 0; i < string.length; i++) { view.setUint8(offset + i, string.charCodeAt(i)); } } // Gameboy Channel Output // Both of these make it sound off // Latency controls how much delay audio has, larger = more delay, goal is to be as small as possible // Time remaining controls how far ahead we can be., larger = more frames rendered before playing a new set of samples. goal is to be as small as possible. May want to adjust this number according to performance of device // These magic numbers just come from preference, can be set as options const DEFAULT_AUDIO_LATENCY_IN_MILLI = 100; // Some constants that use the ones above that will allow for faster performance const DEFAULT_AUDIO_LATENCY_IN_SECONDS = DEFAULT_AUDIO_LATENCY_IN_MILLI / 1000; // Seems like the super quiet popping, and the wace form spikes in the visualizer, // are caused by the sample rate :P // Thus need to figure out why that is. const WASMBOY_SAMPLE_RATE = 44100; class GbChannelWebAudio { constructor(id) { this.id = id; this.audioContext = undefined; this.audioBuffer = undefined; // The play time for our audio samples this.audioPlaytime = undefined; this.audioSources = []; // Gain Node for muting this.gainNode = undefined; this.muted = false; this.libMuted = false; // Our buffer for recording PCM Samples as they come this.recording = false; this.recordingLeftBuffers = undefined; this.recordingRightBuffers = undefined; this.recordingAudioBuffer = undefined; this.recordingAnchor = undefined; } createAudioContextIfNone() { if (!this.audioContext && typeof window !== 'undefined') { // Get our Audio context this.audioContext = new (window.AudioContext || window.webkitAudioContext)(); // Set up our nodes // Seems like closure compiler will optimize this out // Thus, need to do a very specifc type check if statement here. if (!!this.audioContext === true) { this.gainNode = this.audioContext.createGain(); } } } getCurrentTime() { this.createAudioContextIfNone(); if (!this.audioContext) { return; } return this.audioContext.currentTime; } getPlayTime() { return this.audioPlaytime; } resumeAudioContext() { this.createAudioContextIfNone(); if (!this.audioContext) { return; } if (this.audioContext.state === 'suspended') { this.audioContext.resume(); this.audioPlaytime = this.audioContext.currentTime; } } playAudio(numberOfSamples, leftChannelBuffer, rightChannelBuffer, playbackRate, updateAudioCallback) { if (!this.audioContext) { return; } // Get our buffers as floats const leftChannelBufferAsFloat = new Float32Array(leftChannelBuffer); const rightChannelBufferAsFloat = new Float32Array(rightChannelBuffer); // Create an audio buffer, with a left and right channel this.audioBuffer = this.audioContext.createBuffer(2, numberOfSamples, WASMBOY_SAMPLE_RATE); this._setSamplesToAudioBuffer(this.audioBuffer, leftChannelBufferAsFloat, rightChannelBufferAsFloat); if (this.recording) { this.recordingLeftBuffers.push(leftChannelBufferAsFloat); this.recordingRightBuffers.push(rightChannelBufferAsFloat); } // Get an AudioBufferSourceNode. // This is the AudioNode to use when we want to play an AudioBuffer let source = this.audioContext.createBufferSource(); // set the buffer in the AudioBufferSourceNode source.buffer = this.audioBuffer; // Set our playback rate for time resetretching source.playbackRate.setValueAtTime(playbackRate, this.audioContext.currentTime); // Set up our "final node", as in the one that will be connected // to the destination (output) let finalNode = source; // Call our callback/plugins, if we have one if (updateAudioCallback) { const responseNode = updateAudioCallback(this.audioContext, finalNode, this.id); if (responseNode) { finalNode = responseNode; } } // Call our plugins WasmBoyPlugins.runHook({ key: 'audio', params: [this.audioContext, finalNode, this.id], callback: hookResponse => { if (hookResponse) { finalNode.connect(hookResponse); finalNode = hookResponse; } } }); // Lastly, apply our gain node to mute/unmute if (this.gainNode) { finalNode.connect(this.gainNode); finalNode = this.gainNode; } // connect the AudioBufferSourceNode to the // destination so we can hear the sound finalNode.connect(this.audioContext.destination); // Check if we made it in time // Idea from: https://github.com/binji/binjgb/blob/master/demo/demo.js let audioContextCurrentTime = this.audioContext.currentTime; let audioContextCurrentTimeWithLatency = audioContextCurrentTime + DEFAULT_AUDIO_LATENCY_IN_SECONDS; this.audioPlaytime = this.audioPlaytime || audioContextCurrentTimeWithLatency; if (this.audioPlaytime < audioContextCurrentTime) { // We took too long, or something happen and hiccup'd the emulator, reset audio playback times this.cancelAllAudio(); this.audioPlaytime = audioContextCurrentTimeWithLatency; } // start the source playing source.start(this.audioPlaytime); // Set our new audio playtime goal const sourcePlaybackLength = numberOfSamples / (WASMBOY_SAMPLE_RATE * playbackRate); this.audioPlaytime = this.audioPlaytime + sourcePlaybackLength; // Cancel all audio sources on the tail that play before us while (this.audioSources[this.audioSources.length - 1] && this.audioSources[this.audioSources.length - 1].playtime <= this.audioPlaytime) { this.audioSources[this.audioSources.length - 1].source.stop(); this.audioSources.pop(); } // Add the source so we can stop this if needed this.audioSources.push({ source: source, playTime: this.audioPlaytime }); // Shift ourselves out when finished const timeUntilSourceEnds = this.audioPlaytime - this.audioContext.currentTime + 500; setTimeout(() => { this.audioSources.shift(); }, timeUntilSourceEnds); } cancelAllAudio(stopCurrentAudio) { if (!this.audioContext) { return; } // Cancel all audio That was queued to play for (let i = 0; i < this.audioSources.length; i++) { if (stopCurrentAudio || this.audioSources[i].playTime > this.audioPlaytime) { this.audioSources[i].source.stop(); } } this.audioSources = []; // Reset our audioPlaytime this.audioPlaytime = this.audioContext.currentTime + DEFAULT_AUDIO_LATENCY_IN_SECONDS; } mute() { if (!this.muted) { this._setGain(0); this.muted = true; } } unmute() { if (this.muted) { this._setGain(1); this.muted = false; } } hasRecording() { return !!this.recordingAudioBuffer; } startRecording() { if (!this.recording) { this.recording = true; this.recordingLeftBuffers = []; this.recordingRightBuffers = []; this.recordingAudioBuffer = undefined; } } stopRecording() { // Check if we were recoridng if (!this.recording) { return; } this.recording = false; // Create a left/right buffer from all the buffers stored const createBufferFromBuffers = buffers => { let totalLength = 0; buffers.forEach(buffer => { totalLength += buffer.length; }); const totalBuffer = new Float32Array(totalLength); let currentLength = 0; buffers.forEach(buffer => { totalBuffer.set(buffer, currentLength); currentLength += buffer.length; }); return totalBuffer; }; const totalLeftBuffer = createBufferFromBuffers(this.recordingLeftBuffers); const totalRightBuffer = createBufferFromBuffers(this.recordingRightBuffers); this.recordingAudioBuffer = this.audioContext.createBuffer(2, totalLeftBuffer.length, WASMBOY_SAMPLE_RATE); this._setSamplesToAudioBuffer(this.recordingAudioBuffer, totalLeftBuffer, totalRightBuffer); this.recordingLeftBuffer = undefined; this.recordingRightBuffer = undefined; } downloadRecordingAsWav(filename) { if (!this.recordingAudioBuffer) { return; } // Check if we need to create our anchor tag // Which is used to download the audio if (!this.recordingAnchor) { this.recordingAnchor = document.createElement('a'); document.body.appendChild(this.recordingAnchor); this.recordingAnchor.style = 'display: none'; } // Create our wav as a downloadable blob const wav = index(this.recordingAudioBuffer); const blob = new window.Blob([new DataView(wav)], { type: 'audio/wav' }); // Create our url / download name const url = window.URL.createObjectURL(blob); this.recordingAnchor.href = url; let downloadName; if (filename) { downloadName = `${filename}.wav`; } else { const shortDateWithTime = new Date().toLocaleDateString(undefined, { month: '2-digit', day: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' }); downloadName = `wasmboy-${shortDateWithTime}.wav`; } this.recordingAnchor.download = downloadName; // Download our wav this.recordingAnchor.click(); window.URL.revokeObjectURL(url); } getRecordingAsWavBase64EncodedString() { if (!this.recordingAudioBuffer) { return; } // Create our wav as a downloadable blob const wav = index(this.recordingAudioBuffer); const base64String = this._arrayBufferToBase64(wav); return `data:audio/wav;base64,${base64String}`; } getRecordingAsAudioBuffer() { return this.recordingAudioBuffer; } _libMute() { this._setGain(0); this.libMuted = true; } _libUnmute() { if (this.libMuted) { this._setGain(1); this.libMuted = false; } } _setGain(gain) { this.createAudioContextIfNone(); if (this.gainNode) { this.gainNode.gain.setValueAtTime(gain, this.audioContext.currentTime); } } _setSamplesToAudioBuffer(audioBuffer, leftChannelSamples, rightChannelSamples) { if (audioBuffer.copyToChannel) { audioBuffer.copyToChannel(leftChannelSamples, 0, 0); audioBuffer.copyToChannel(rightChannelSamples, 1, 0); } else { // Safari fallback audioBuffer.getChannelData(0).set(leftChannelSamples); audioBuffer.getChannelData(1).set(rightChannelSamples); } } // https://stackoverflow.com/questions/9267899/arraybuffer-to-base64-encoded-string/38858127 _arrayBufferToBase64(buffer) { let binary = ''; let bytes = new Uint8Array(buffer); let len = bytes.byteLength; for (var i = 0; i < len; i++) { binary += String.fromCharCode(bytes[i]); } return window.btoa(binary); } } // Tons of help from: const SLOW_TIME_STRETCH_MIN_FPS = 57; class WasmBoyAudioService { constructor() { // Wasmboy instance and memory this.worker = undefined; this.updateAudioCallback = undefined; // Our Channels this.gbChannels = { master: new GbChannelWebAudio('master'), channel1: new GbChannelWebAudio('channel1'), channel2: new GbChannelWebAudio('channel2'), channel3: new GbChannelWebAudio('channel3'), channel4: new GbChannelWebAudio('channel4') }; this._createAudioContextIfNone(); // Mute all the child channels, // As we will assume all channels are enabled if (typeof window !== 'undefined') { this.gbChannels.channel1._libMute(); this.gbChannels.channel2._libMute(); this.gbChannels.channel3._libMute(); this.gbChannels.channel4._libMute(); } // Average fps for time stretching this.averageTimeStretchFps = []; this.speed = 1.0; // Our sound output Location, we will initialize this in init this.WASMBOY_SOUND_OUTPUT_LOCATION = 0; this.WASMBOY_CHANNEL_1_OUTPUT_LOCATION = 0; this.WASMBOY_CHANNEL_2_OUTPUT_LOCATION = 0; this.WASMBOY_CHANNEL_3_OUTPUT_LOCATION = 0; this.WASMBOY_CHANNEL_4_OUTPUT_LOCATION = 0; } initialize(updateAudioCallback) { const initializeTask = async () => { this.updateAudioCallback = updateAudioCallback; this.averageTimeStretchFps = []; this.speed = 1.0; this._createAudioContextIfNone(); this.cancelAllAudio(); // Lastly get our audio constants return this.worker.postMessage({ type: WORKER_MESSAGE_TYPE.GET_CONSTANTS }); }; return initializeTask(); } setWorker(worker) { this.worker = worker; this.worker.addMessageListener(event => { const eventData = getEventData(event); switch (eventData.message.type) { case WORKER_MESSAGE_TYPE.UPDATED: { // Dont wait for raf. // Audio being shown is not dependent on the browser drawing a frame :) // Just send the message directly this.playAudio(eventData.message); // Next, send back how much forward latency // we have let latency = 0; let currentTime = this.gbChannels.master.getCurrentTime(); let playtime = this.gbChannels.master.getPlayTime(); if (currentTime && currentTime > 0) { latency = playtime - currentTime; } this.worker.postMessageIgnoreResponse({ type: WORKER_MESSAGE_TYPE.AUDIO_LATENCY, latency }); return; } } }); } getAudioChannels() { return this.gbChannels; } setSpeed(speed) { this.speed = speed; this.cancelAllAudio(true); this.resetTimeStretch(); } resetTimeStretch() { // Simply reset our average FPS counter array this.averageTimeStretchFps = []; } // Function to queue up and audio buyffer to be played // Returns a promise so that we may "sync by audio" // https://www.reddit.com/r/EmuDev/comments/5gkwi5/gb_apu_sound_emulation/dau8e2w/ playAudio(audioMessage) { let currentFps = audioMessage.fps; let allowFastSpeedStretching = audioMessage.allowFastSpeedStretching; let numberOfSamples = audioMessage.numberOfSamples; // Find our averageFps let fps = currentFps || 60; // Check if we got a huge fps outlier. // If so, let's just reset our average. // This will fix the slow gradual ramp down const fpsDifference = Math.abs(currentFps - this.averageTimeStretchFps[this.averageTimeStretchFps.length - 1]); if (fpsDifference && fpsDifference >= 15) { this.resetTimeStretch(); } // Find our average fps for time stretching this.averageTimeStretchFps.push(currentFps); // TODO Make the multiplier Const the timeshift speed if (this.averageTimeStretchFps.length > Math.floor(SLOW_TIME_STRETCH_MIN_FPS * 3)) { this.averageTimeStretchFps.shift(); } // Make sure we have a minimum number of time stretch fps timestamps to judge the average time if (this.averageTimeStretchFps.length >= SLOW_TIME_STRETCH_MIN_FPS) { fps = this.averageTimeStretchFps.reduce((accumulator, currentValue) => { return accumulator + currentValue; }); fps = Math.floor(fps / this.averageTimeStretchFps.length); } // Find if we should time stretch this sample or not from our current fps let playbackRate = 1.0; let shouldTimeStretch = (fps < SLOW_TIME_STRETCH_MIN_FPS || allowFastSpeedStretching) && this.speed === 1.0; if (shouldTimeStretch) { // Has to be 60 to get accurent playback regarless of fps cap playbackRate = playbackRate * (fps / 60); if (playbackRate <= 0) { playbackRate = 0.01; } } // Apply our speed to the playback rate playbackRate = playbackRate * this.speed; // Play the master channel this.gbChannels.master.playAudio(numberOfSamples, audioMessage.audioBuffer.left, audioMessage.audioBuffer.right, playbackRate, this.updateAudioCallback); // Play on all of our channels if we have buffers for them for (let i = 0; i < 4; i++) { let channelNumber = i + 1; if (audioMessage[`channel${channelNumber}Buffer`]) { this.gbChannels[`channel${channelNumber}`].playAudio(numberOfSamples, audioMessage[`channel${channelNumber}Buffer`].left, audioMessage[`channel${channelNumber}Buffer`].right, playbackRate, this.updateAudioCallback); } } let playingAllChannels = !this.gbChannels.channel1.muted && !this.gbChannels.channel2.muted && !this.gbChannels.channel3.muted && !this.gbChannels.channel4.muted; // Mute and unmute accordingly if (this.gbChannels.master.muted && playingAllChannels) { this.gbChannels.master.unmute(); // We want to "force" mute here // Because master is secretly playing all the audio, // But we want the channels to appear not muted :) this.gbChannels.channel1._libMute(); this.gbChannels.channel2._libMute(); this.gbChannels.channel3._libMute(); this.gbChannels.channel4._libMute(); } else if (!this.gbChannels.master.muted && !playingAllChannels) { this.gbChannels.master.mute(); this.gbChannels.channel1._libUnmute(); this.gbChannels.channel2._libUnmute(); this.gbChannels.channel3._libUnmute(); this.gbChannels.channel4._libUnmute(); } } // Functions to simply run on all of our channels // Ensure that Audio is blessed. // Meaning, the audioContext won't be // affected by any autoplay issues. // https://www.chromium.org/audio-video/autoplay resumeAudioContext() { this._applyOnAllChannels('resumeAudioContext'); } cancelAllAudio(stopCurrentAudio) { this._applyOnAllChannels('cancelAllAudio', [stopCurrentAudio]); } _createAudioContextIfNone() { this._applyOnAllChannels('createAudioContextIfNone'); } _applyOnAllChannels(functionKey, argsArray) { Object.keys(this.gbChannels).forEach(gbChannelKey => { this.gbChannels[gbChannelKey][functionKey].apply(this.gbChannels[gbChannelKey], argsArray); }); } } const WasmBoyAudio = new WasmBoyAudioService(); function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; } function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; } function _objectSpread2(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; } var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof window !== 'undefined' ? window : typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : {}; function createCommonjsModule(fn, module) { return module = { exports: {} }, fn(module, module.exports), module.exports; } var idb = createCommonjsModule(function (module) { (function() { function toArray(arr) { return Array.prototype.slice.call(arr); } function promisifyRequest(request) { return new Promise(function(resolve, reject) { request.onsuccess = function() { resolve(request.result); }; request.onerror = function() { reject(request.error); }; }); } function promisifyRequestCall(obj, method, args) { var request; var p = new Promise(function(resolve, reject) { request = obj[method].apply(obj, args); promisifyRequest(request).then(resolve, reject); }); p.request = request; return p; } function promisifyCursorRequestCall(obj, method, args) { var p = promisifyRequestCall(obj, method, args); return p.then(function(value) { if (!value) return; return new Cursor(value, p.request); }); } function proxyProperties(ProxyClass, targetProp, properties) { properties.forEach(function(prop) { Object.defineProperty(ProxyClass.prototype, prop, { get: function() { return this[targetProp][prop]; }, set: function(val) { this[targetProp][prop] = val; } }); }); } function proxyRequestMethods(ProxyClass, targetProp, Constructor, properties) { properties.forEach(function(prop) { if (!(prop in Constructor.prototype)) return; ProxyClass.prototype[prop] = function() { return promisifyRequestCall(this[targetProp], prop, arguments); }; }); } function proxyMethods(ProxyClass, targetProp, Constructor, properties) { properties.forEach(function(prop) { if (!(prop in Constructor.prototype)) return; ProxyClass.prototype[prop] = function() { return this[targetProp][prop].apply(this[targetProp], arguments); }; }); } function proxyCursorRequestMethods(ProxyClass, targetProp, Constructor, properties) { properties.forEach(function(prop) { if (!(prop in Constructor.prototype)) return; ProxyClass.prototype[prop] = function() { return promisifyCursorRequestCall(this[targetProp], prop, arguments); }; }); } function Index(index) { this._index = index; } proxyProperties(Index, '_index', [ 'name', 'keyPath', 'multiEntry', 'unique' ]); proxyRequestMethods(Index, '_index', IDBIndex, [ 'get', 'getKey', 'getAll', 'getAllKeys', 'count' ]); proxyCursorRequestMethods(Index, '_index', IDBIndex, [ 'openCursor', 'openKeyCursor' ]); function Cursor(cursor, request) { this._cursor = cursor; this._request = request; } proxyProperties(Cursor, '_cursor', [ 'direction', 'key', 'primaryKey', 'value' ]); proxyRequestMethods(Cursor, '_cursor', IDBCursor, [ 'update', 'delete' ]); // proxy 'next' methods ['advance', 'continue', 'continuePrimaryKey'].forEach(function(methodName) { if (!(methodName in IDBCursor.prototype)) return; Cursor.prototype[methodName] = function() { var cursor = this; var args = arguments; return Promise.resolve().then(function() { cursor._cursor[methodName].apply(cursor._cursor, args); return promisifyRequest(cursor._request).then(function(value) { if (!value) return; return new Cursor(value, cursor._request); }); }); }; }); function ObjectStore(store) { this._store = store; } ObjectStore.prototype.createIndex = function() { return new Index(this._store.createIndex.apply(this._store, arguments)); }; ObjectStore.prototype.index = function() { return new Index(this._store.index.apply(this._store, arguments)); }; proxyProperties(ObjectStore, '_store', [ 'name', 'keyPath', 'indexNames', 'autoIncrement' ]); proxyRequestMethods(ObjectStore, '_store', IDBObjectStore, [ 'put', 'add', 'delete', 'clear', 'get', 'getAll', 'getKey', 'getAllKeys', 'count' ]); proxyCursorRequestMethods(ObjectStore, '_store', IDBObjectStore, [ 'openCursor', 'openKeyCursor' ]); proxyMethods(ObjectStore, '_store', IDBObjectStore, [ 'deleteIndex' ]); function Transaction(idbTransaction) { this._tx = idbTransaction; this.complete = new Promise(function(resolve, reject) { idbTransaction.oncomplete = function() { resolve(); }; idbTransaction.onerror = function() { reject(idbTransaction.error); }; idbTransaction.onabort = function() { reject(idbTransaction.error); }; }); } Transaction.prototype.objectStore = function() { return new ObjectStore(this._tx.objectStore.apply(this._tx, arguments)); }; proxyProperties(Transaction, '_tx', [ 'objectStoreNames', 'mode' ]); proxyMethods(Transaction, '_tx', IDBTransaction, [ 'abort' ]); function UpgradeDB(db, oldVersion, transaction) { this._db = db; this.oldVersion = oldVersion; this.transaction = new Transaction(transaction); } UpgradeDB.prototype.createObjectStore = function() { return new ObjectStore(this._db.createObjectStore.apply(this._db, arguments)); }; proxyProperties(UpgradeDB, '_db', [ 'name', 'version', 'objectStoreNames' ]); proxyMethods(UpgradeDB, '_db', IDBDatabase, [ 'deleteObjectStore', 'close' ]); function DB(db) { this._db = db; } DB.prototype.transaction = function() { return new Transaction(this._db.transaction.apply(this._db, arguments)); }; proxyProperties(DB, '_db', [ 'name', 'version', 'objectStoreNames' ]); proxyMethods(DB, '_db', IDBDatabase, [ 'close' ]); // Add cursor iterators // TODO: remove this once browsers do the right thing with promises ['openCursor', 'openKeyCursor'].forEach(function(funcName) { [ObjectStore, Index].forEach(function(Constructor) { // Don't create iterateKeyCursor if openKeyCursor doesn't exist. if (!(funcName in Constructor.prototype)) return; Constructor.prototype[funcName.replace('open', 'iterate')] = function() { var args = toArray(arguments); var callback = args[args.length - 1]; var nativeObject = this._store || this._index; var request = nativeObject[funcName].apply(nativeObject, args.slice(0, -1)); request.onsuccess = function() { callback(request.result); }; }; }); }); // polyfill getAll [Index, ObjectStore].forEach(function(Constructor) { if (Constructor.prototype.getAll) return; Constructor.prototype.getAll = function(query, count) { var instance = this; var items = []; return new Promise(function(resolve) { instance.iterateCursor(query, function(cursor) { if (!cursor) { resolve(items); return; } items.push(cursor.value); if (count !== undefined && items.length == count) { resolve(items); return; } cursor.continue(); }); }); }; }); var exp = { open: function(name, version, upgradeCallback) { var p = promisifyRequestCall(indexedDB, 'open', [name, version]); var request = p.request; if (request) { request.onupgradeneeded = function(event) { if (upgradeCallback) { upgradeCallback(new UpgradeDB(request.result, event.oldVersion, request.transaction)); } }; } return p.then(function(db) { return new DB(db); }); }, delete: function(name) { return promisifyRequestCall(indexedDB, 'deleteDatabase', [name]); } }; { module.exports = exp; module.exports.default = module.exports; } }()); }); var node = createCommonjsModule(function (module) { if (typeof indexedDB != 'undefined') { module.exports = idb; } else { module.exports = { open: function () { return Promise.reject('IDB requires a browser environment'); }, delete: function () { return Promise.reject('IDB requires a browser environment'); } }; } }); var node_1 = node.open; // Get our idb instance, and initialize to asn idb-keyval let keyval = false; // Get our idb dPromise if (typeof window !== 'undefined') { const dbPromise = node.open('wasmboy', 1, upgradeDB => { upgradeDB.createObjectStore('keyval'); }); // Get our idb-keyval instance keyval = { get(key) { return dbPromise.then(db => { return db.transaction('keyval').objectStore('keyval').get(key); }); }, set(key, val) { return dbPromise.then(db => { const tx = db.transaction('keyval', 'readwrite'); tx.objectStore('keyval').put(val, key); return tx.complete; }); }, delete(key) { return dbPromise.then(db => { const tx = db.transaction('keyval', 'readwrite'); tx.objectStore('keyval').delete(key); return tx.complete; }); }, clear() { return dbPromise.then(db => { const tx = db.transaction('keyval', 'readwrite'); tx.objectStore('keyval').clear(); return tx.complete; }); }, keys() { return dbPromise.then(db => { const tx = db.transaction('keyval'); const keys = []; const store = tx.objectStore('keyval'); // This would be store.getAllKeys(), but it isn't supported by Edge or Safari. // openKeyCursor isn't supported by Safari, so we fall back (store.iterateKeyCursor || store.iterateCursor).call(store, cursor => { if (!cursor) return; keys.push(cursor.key); cursor.continue(); }); return tx.complete.then(() => keys); }); } }; } else { // Create a mock keyval for node keyval = { get: () => {}, set: () => {}, delete: () => {}, clear: () => {}, keys: () => {} }; } const idbKeyval = keyval; // Taken/Modified From: https://github.com/photopea/UZIP.js let UZIP = {}; // Make it a hacky es module const uzip = UZIP; UZIP['parse'] = function (buf // ArrayBuffer ) { let rUs = UZIP.bin.readUshort, rUi = UZIP.bin.readUint, o = 0, out = {}; let data = new Uint8Array(buf); let eocd = data.length - 4; while (rUi(data, eocd) != 0x06054b50) eocd--; o = eocd; o += 4; // sign = 0x06054b50 o += 4; // disks = 0; let cnu = rUs(data, o); o += 2; let cnt = rUs(data, o); o += 2; let csize = rUi(data, o); o += 4; let coffs = rUi(data, o); o += 4; o = coffs; for (let i = 0; i < cnu; i++) { let sign = rUi(data, o); o += 4; o += 4; // versions; o += 4; // flag + compr o += 4; // time let crc32 = rUi(data, o); o += 4; let csize = rUi(data, o); o += 4; let usize = rUi(data, o); o += 4; let nl = rUs(data, o), el = rUs(data, o + 2), cl = rUs(data, o + 4); o += 6; // name, extra, comment o += 8; // disk, attribs let roff = rUi(data, o); o += 4; o += nl + el + cl; UZIP._readLocal(data, roff, out, csize, usize); } //console.log(out); return out; }; UZIP._readLocal = function (data, o, out, csize, usize) { let rUs = UZIP.bin.readUshort, rUi = UZIP.bin.readUint; let sign = rUi(data, o); o += 4; let ver = rUs(data, o); o += 2; let gpflg = rUs(data, o); o += 2; //if((gpflg&8)!=0) throw "unknown sizes"; let cmpr = rUs(data, o); o += 2; let time = rUi(data, o); o += 4; let crc32 = rUi(data, o); o += 4; //let csize = rUi(data, o); o+=4; //let usize = rUi(data, o); o+=4; o += 8; let nlen = rUs(data, o); o += 2; let elen = rUs(data, o); o += 2; let name = UZIP.bin.readUTF8(data, o, nlen); o += nlen; o += elen; //console.log(sign.toString(16), ver, gpflg, cmpr, crc32.toString(16), "csize, usize", csize, usize, nlen, elen, name, o); let file = new Uint8Array(data.buffer, o); if (cmpr == 0) out[name] = new Uint8Array(file.buffer.slice(o, o + csize));else if (cmpr == 8) { let buf = new Uint8Array(usize); UZIP.inflateRaw(file, buf); //let nbuf = pako["inflateRaw"](file); //for(let i=0; i<buf.length; i++) if(buf[i]!=nbuf[i]) { console.log(buf.length, nbuf.length, usize, i); throw "e"; } out[name] = buf; } else throw 'unknown compression method: ' + cmpr; }; UZIP.inflateRaw = function (file, buf) { return UZIP.F.inflate(file, buf); }; UZIP.inflate = function (file, buf) { let CMF = file[0], FLG = file[1]; return UZIP.inflateRaw(new Uint8Array(file.buffer, file.byteOffset + 2, file.length - 6), buf); }; UZIP.deflate = function (data, opts /*, buf, off*/ ) { if (opts == null) opts = { level: 6 }; let off = 0, buf = new Uint8Array(50 + Math.floor(data.length * 1.1)); buf[off] = 120; buf[off + 1] = 156; off += 2; off = UZIP.F.deflateRaw(data, buf, off, opts.level); let crc = UZIP.adler(data, 0, data.length); buf[off + 0] = crc >>> 24 & 255; buf[off + 1] = crc >>> 16 & 255; buf[off + 2] = crc >>> 8 & 255; buf[off + 3] = crc >>> 0 & 255; return new Uint8Array(buf.buffer, 0, off + 4); }; UZIP.deflateRaw = function (data, opts) { if (opts == null) opts = { level: 6 }; let buf = new Uint8Array(50 + Math.floor(data.length * 1.1)); let off; off = UZIP.F.deflateRaw(data, buf, off, opts.level); return new Uint8Array(buf.buffer, 0, off); }; UZIP.encode = function (obj) { let tot = 0, wUi = UZIP.bin.writeUint, wUs = UZIP.bin.writeUshort; let zpd = {}; for (let p in obj) { let cpr = !UZIP._noNeed(p), buf = obj[p], crc = UZIP.crc.crc(buf, 0, buf.length); zpd[p] = { cpr: cpr, usize: buf.length, crc: crc, file: cpr ? UZIP.deflateRaw(buf) : buf }; } for (let p in zpd) tot += zpd[p].file.length + 30 + 46 + 2 * UZIP.bin.sizeUTF8(p); tot += 22; let data = new Uint8Array(tot), o = 0; let fof = []; for (let p in zpd) { let file = zpd[p]; fof.push(o); o = UZIP._writeHeader(data, o, p, file, 0); } let i = 0, ioff = o; for (let p in zpd) { let file = zpd[p]; fof.push(o); o = UZIP._writeHeader(data, o, p, file, 1, fof[i++]); } let csize = o - ioff; wUi(data, o, 0x06054b50); o += 4; o += 4; // disks wUs(data, o, i); o += 2; wUs(data, o, i); o += 2; // number of c d records wUi(data, o, csize); o += 4; wUi(data, o, ioff); o += 4; o += 2; return data.buffer; }; // no need to compress .PNG, .ZIP, .JPEG .... UZIP._noNeed = function (fn) { let ext = fn.split('.').pop().toLowerCase(); return 'png,jpg,jpeg,zip'.indexOf(ext) != -1; }; UZIP._writeHeader = function (data, o, p, obj, t, roff) { let wUi = UZIP.bin.writeUint, wUs = UZIP.bin.writeUshort; let file = obj.file; wUi(data, o, t == 0 ? 0x04034b50 : 0x02014b50); o += 4; // sign if (t == 1) o += 2; // ver made by wUs(data, o, 20); o += 2; // ver wUs(data, o, 0); o += 2; // gflip wUs(data, o, obj.cpr ? 8 : 0); o += 2; // cmpr wUi(data, o, 0); o += 4; // time wUi(data, o, obj.crc); o += 4; // crc32 wUi(data, o, file.length); o += 4; // csize wUi(data, o, obj.usize); o += 4; // usize wUs(data, o, UZIP.bin.sizeUTF8(p)); o += 2; // nlen wUs(data, o, 0); o += 2; // elen if (t == 1) { o += 2; // comment length o += 2; // disk number o += 6; // attributes wUi(data, o, roff); o += 4; // usize } let nlen = UZIP.bin.writeUTF8(data, o, p); o += nlen; if (t == 0) { data.set(file, o); o += file.length; } return o; }; UZIP.crc = { table: function () { let tab = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) { if (c & 1) c = 0xedb88320 ^ c >>> 1;else c = c >>> 1; } tab[n] = c; } return tab; }(), update: function (c, buf, off, len) { for (let i = 0; i < len; i++) c = UZIP.crc.table[(c ^ buf[off + i]) & 0xff] ^ c >>> 8; return c; }, crc: function (b, o, l) { return UZIP.crc.update(0xffffffff, b, o, l) ^ 0xffffffff; } }; UZIP.adler = function (data, o, len) { let a = 1, b = 0; let off = o, end = o + len; while (off < end) { let eend = Math.min(off + 5552, end); while (off < eend) { a += data[off++]; b += a; } a = a % 65521; b = b % 65521; } return b << 16 | a; }; UZIP.bin = { readUshort: function (buff, p) { return buff[p] | buff[p + 1] << 8; }, writeUshort: function (buff, p, n) { buff[p] = n & 255; buff[p + 1] = n >> 8 & 255; }, readUint: function (buff, p) { return buff[p + 3] * (256 * 256 * 256) + (buff[p + 2] << 16 | buff[p + 1] << 8 | buff[p]); }, writeUint: function (buff, p, n) { buff[p] = n & 255; buff[p + 1] = n >> 8 & 255; buff[p + 2] = n >> 16 & 255; buff[p + 3] = n >> 24 & 255; }, readASCII: function (buff, p, l) { let s = ''; for (let i = 0; i < l; i++) s += String.fromCharCode(buff[p + i]); return s; }, writeASCII: function (data, p, s) { for (let i = 0; i < s.length; i++) data[p + i] = s.charCodeAt(i); }, pad: function (n) { return n.length < 2 ? '0' + n : n; }, readUTF8: function (buff, p, l) { let s = '', ns; for (let i = 0; i < l; i++) s += '%' + UZIP.bin.pad(buff[p + i].toString(16)); try { ns = decodeURIComponent(s); } catch (e) { return UZIP.bin.readASCII(buff, p, l); } return ns; }, writeUTF8: function (buff, p, str) { let strl = str.length, i = 0; for (let ci = 0; ci < strl; ci++) { let code = str.charCodeAt(ci); if ((code & 0xffffffff - (1 << 7) + 1) == 0) { buff[p + i] = code; i++; } else if ((code & 0xffffffff - (1 << 11) + 1) == 0) { buff[p + i] = 192 | code >> 6; buff[p + i + 1] = 128 | code >> 0 & 63; i += 2; } else if ((code & 0xffffffff - (1 << 16) + 1) == 0) { buff[p + i] = 224 | code >> 12; buff[p + i + 1] = 128 | code >> 6 & 63; buff[p + i + 2] = 128 | code >> 0 & 63; i += 3; } else if ((code & 0xffffffff - (1 << 21) + 1) == 0) { buff[p + i] = 240 | code >> 18; buff[p + i + 1] = 128 | code >> 12 & 63; buff[p + i + 2] = 128 | code >> 6 & 63; buff[p + i + 3] = 128 | code >> 0 & 63; i += 4; } else throw 'e'; } return i; }, sizeUTF8: function (str) { let strl = str.length, i = 0; for (let ci = 0; ci < strl; ci++) { let code = str.charCodeAt(ci); if ((code & 0xffffffff - (1 << 7) + 1) == 0) { i++; } else if ((code & 0xffffffff - (1 << 11) + 1) == 0) { i += 2; } else if ((code & 0xffffffff - (1 << 16) + 1) == 0) { i += 3; } else if ((code & 0xffffffff - (1 << 21) + 1) == 0) { i += 4; } else throw 'e'; } return i; } }; UZIP.F = {}; UZIP.F.deflateRaw = function (data, out, opos, lvl) { let opts = [ /* ush good_length; /* reduce lazy search above this match length ush max_lazy; /* do not perform lazy search above this match length ush nice_length; /* quit search above this match length */ /* good lazy nice chain */ /* 0 *