UNPKG

@soapbox.pub/wasmboy

Version:

Soapbox fork of Wasmboy.

367 lines (302 loc) 11.1 kB
// Gameboy Channel Output // With outputting to Web Audio API import { WasmBoyPlugins } from '../plugins/plugins'; import toWav from 'audiobuffer-to-wav'; // Define our performance constants // 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; export default 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 = toWav(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 = toWav(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); } }