diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..cfd9c84 --- /dev/null +++ b/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["stage-0"], + "plugins": ["transform-es2015-classes"] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..40b878d --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..689b3db --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +# Crunker + +Simple way to merge, concatenate, play, export and download audio files with the Web Audio API. + +# Example + +```javascript +let audio = new Crunker(); + +audio.fetchAudio('/song.mp3', '/another-song.mp3') + .then(buffers => { + // => [AudioBuffer, AudioBuffer] + audio.mergeAudio(buffers) + }) + .then(merged => { + // => AudioBuffer + audio.export(merged, 'audio/mp3') + }) + .then(output => { + // => {blob, element, url} + audio.download(output.blob); + document.append(output.element); + console.log(output.url); + }); + .catch((error) => { + // => Error Message + }); + +audio.notSupported(() => { + // Handle no browser support +}); +``` + +# Condensed Example + +```javascript +let audio = new Crunker(); + +audio.fetchAudio('/voice.mp3', '/shell.mp3') + .then(buffers => audio.mergeAudio(buffers)) + .then(merged => audio.export(merged, 'audio/mp3')) + .then(output => audio.download(output.audio)}) + .catch(error => throw new Error(error)) +``` + +# Methods + +## new Crunker() + +Create a new Crunker, no configuration options are required. + +## crunker.fetchAudio(songURL, anotherSongURL) + +Fetch one or more audio files. +Returns: an array of audio buffers in the order they were fetched. + +## crunker.mergeAudio(arrayOfBuffers); + +Merge two or more audio buffers. +Returns: a single AudioBuffer object. + +## crunker.concatAudio(arrayOfBuffers); + +Concatenate two or more audio buffers in the order specified. +Returns: a single AudioBuffer object. + +## crunker.export(buffer, type); + +Export an audio buffers with MIME type option. +Type: `'audio/mp3', 'audio/wav', 'audio/ogg'`. +Returns: an object containing the blob object, url, and an audio element object. + +## crunker.download(blob, filename); + +Automatically download an exported audio blob with optional filename. +Returns: the `` element used to simulate the automatic download. + +## crunker.download(blob, filename); + +Automatically download an exported audio blob with optional filename. +Filename: String not containing the .mp3, .wav, or .ogg file extension. +Returns: the `` element used to simulate the automatic download. + +## crunker.play(blob); + +Starts playing the exported audio blob in the background. +Returns: the audio source object. + +## audio.notSupported(callback); + +Execute custom code if Web Audio API is not supported by the users browser. +Returns: The callback function. + +# License + +MIT diff --git a/package.json b/package.json new file mode 100644 index 0000000..f007fea --- /dev/null +++ b/package.json @@ -0,0 +1,41 @@ +{ + "name": "crunker", + "version": "0.0.1", + "description": "Simple way to merge or concatenate audio files with the Web Audio API.", + "main": "src/crunker.js", + "directories": { + "test": "test", + "src": "src" + }, + "scripts": { + "test": "npm run compile && mocha-phantomjs -p node_modules/phantomjs/bin/phantomjs test/test.html", + "compile": "babel src/crunker.js > dist/crunker.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/jackedgson/crunker.git" + }, + "keywords": [ + "web-audio-api", + "es6", + "merge", + "concatonate", + "append", + "export", + "download" + ], + "author": "Jack Edgson", + "license": "MIT", + "bugs": { + "url": "https://github.com/jackedgson/crunker/issues" + }, + "homepage": "https://github.com/jackedgson/crunker#readme", + "devDependencies": { + "babel-cli": "^6.24.1", + "babel-plugin-transform-es2015-classes": "^6.24.1", + "babel-preset-stage-0": "^6.24.1", + "chai": "^3.5.0", + "mocha-phantomjs": "3.4.1", + "phantomjs": "^2.1.7" + } +} diff --git a/src/crunker.js b/src/crunker.js new file mode 100644 index 0000000..6ba8d9e --- /dev/null +++ b/src/crunker.js @@ -0,0 +1,157 @@ +'use strict'; + +class Crunker { + + constructor() { + this._context = this._createContext(); + } + + _createContext() { + window.AudioContext = window.AudioContext || window.webkitAudioContext; + return new AudioContext(); + } + + async fetchAudio(...filepaths) { + const files = filepaths.map(async filepath => { + const buffer = await fetch(filepath).then(response => response.arrayBuffer()); + return await this._context.decodeAudioData(buffer); + }); + return await Promise.all(files); + } + + mergeAudio(buffers) { + let output = this._context.createBuffer(1, 44100*this._maxDuration(buffers), 44100); + + buffers.map(buffer => { + for (let i = buffer.getChannelData(0).length - 1; i >= 0; i--) { + output.getChannelData(0)[i] += buffer.getChannelData(0)[i]; + } + }); + return output; + } + + concatAudio(buffers) { + let output = this._context.createBuffer(1, 44100*this._totalDuration(buffers), 44100), + offset = 0; + buffers.map(buffer => { + output.getChannelData(0).set(buffer.getChannelData(0), offset); + offset += buffer.length; + }); + return output; + } + + play(buffer) { + const source = this._context.createBufferSource(); + source.buffer = buffer; + source.connect(this._context.destination); + source.start(); + return source; + } + + export(buffer, audioType){ + const type = audioType || 'audio/mp3'; + const recorded = this._interleave(buffer); + const dataview = this._writeHeaders(recorded); + const audioBlob = new Blob([dataview], { type: type }); + + return { + blob: audioBlob, + url: this._renderURL(audioBlob), + element: this._renderAudioElement(audioBlob, type), + } + } + + download(blob, filename) { + const name = filename || 'crunker'; + const a = document.createElement("a"); + a.style = "display: none"; + a.href = this._renderURL(blob); + a.download = `${name}.${blob.type.split('/')[1]}`; + a.click(); + return a; + } + + notSupported(callback) { + return !this._isSupported() && callback(); + } + + close() { + this._context.close(); + return this; + } + + _maxDuration(buffers) { + return Math.max.apply(Math, buffers.map(buffer => buffer.duration)); + } + + _totalDuration(buffers) { + return buffers.map(buffer => buffer.duration).reduce((a, b) => a + b, 0); + } + + _isSupported() { + return 'AudioContext' in window; + } + + _writeHeaders(buffer) { + let arrayBuffer = new ArrayBuffer(44 + buffer.length * 2), + view = new DataView(arrayBuffer); + + this._writeString(view, 0, 'RIFF'); + view.setUint32(4, 32 + buffer.length * 2, true); + this._writeString(view, 8, 'WAVE'); + this._writeString(view, 12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 2, true); + view.setUint32(24, 44100, true); + view.setUint32(28, 44100 * 4, true); + view.setUint16(32, 4, true); + view.setUint16(34, 16, true); + this._writeString(view, 36, 'data'); + view.setUint32(40, buffer.length * 2, true); + + return this._floatTo16BitPCM(view, buffer, 44); + } + + _floatTo16BitPCM(dataview, buffer, offset) { + for (var i = 0; i < buffer.length; i++, offset+=2){ + let tmp = Math.max(-1, Math.min(1, buffer[i])); + dataview.setInt16(offset, tmp < 0 ? tmp * 0x8000 : tmp * 0x7FFF, true); + } + return dataview; + } + + _writeString(dataview, offset, header) { + let output; + for (var i = 0; i < header.length; i++){ + dataview.setUint8(offset + i, header.charCodeAt(i)); + } + } + + _interleave(input) { + let buffer = input.getChannelData(0), + length = buffer.length*2, + result = new Float32Array(length), + index = 0, inputIndex = 0; + + while (index < length){ + result[index++] = buffer[inputIndex]; + result[index++] = buffer[inputIndex]; + inputIndex++; + } + return result; + } + + _renderAudioElement(blob, type) { + const audio = document.createElement('audio'); + audio.controls = 'controls'; + audio.type = type; + audio.src = this._renderURL(blob); + return audio; + } + + _renderURL(blob) { + return (window.URL || window.webkitURL).createObjectURL(blob); + } + +}