├── .babelrc ├── .github └── workflows │ └── smoketest.yml ├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-3.3.1.cjs ├── .yarnrc ├── .yarnrc.yml ├── LICENSE ├── README.md ├── examples ├── README.md ├── client │ ├── beatBuilder.html │ └── index.html └── server │ ├── 1.mp3 │ ├── 2.mp3 │ └── index.html ├── karma.conf.js ├── package.json ├── src └── crunker.ts ├── test └── test.js ├── tsconfig.json ├── webpack.config.js ├── webpack.esm.config.js ├── webpack.test.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { "exclude": ["@babel/plugin-transform-regenerator", "@babel/plugin-transform-async-to-generator"] } 6 | ], 7 | "@babel/preset-typescript" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.github/workflows/smoketest.yml: -------------------------------------------------------------------------------- 1 | name: JS 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | check_formatting: 9 | name: Check formatting 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check out code 14 | uses: actions/checkout@v2 15 | 16 | - name: Install Node.js 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: '16' 20 | cache: 'yarn' 21 | 22 | - name: Install dependencies 23 | run: yarn install --immutable 24 | 25 | - name: Check formatting 26 | run: yarn run format:check 27 | 28 | build: 29 | name: Test build 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - name: Check out code 34 | uses: actions/checkout@v2 35 | 36 | - name: Install Node.js 37 | uses: actions/setup-node@v2 38 | with: 39 | node-version: '16' 40 | cache: 'yarn' 41 | 42 | - name: Install dependencies 43 | run: yarn install --immutable 44 | 45 | - name: Check formatting 46 | run: yarn run build 47 | 48 | run_tests: 49 | name: Run tests 50 | runs-on: ubuntu-latest 51 | 52 | steps: 53 | - name: Check out code 54 | uses: actions/checkout@v2 55 | 56 | - name: Install Node.js 57 | uses: actions/setup-node@v2 58 | with: 59 | node-version: '16' 60 | cache: 'yarn' 61 | 62 | - name: Install dependencies 63 | run: yarn install --immutable 64 | 65 | - name: Install Chrome 66 | uses: browser-actions/setup-chrome@latest 67 | 68 | - name: Run tests 69 | run: yarn run test 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | package-lock.json 3 | yarn.lock 4 | yarn-error.log 5 | dist 6 | test/*.bundle.js 7 | 8 | # Yarn 2+ 9 | .pnp.* 10 | .yarn/* 11 | !.yarn/patches 12 | !.yarn/plugins 13 | !.yarn/releases 14 | !.yarn/sdks 15 | !.yarn/versions 16 | 17 | .idea 18 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | *.git 2 | .git 3 | node_modules 4 | test 5 | .gitignore 6 | .npmrc 7 | npm-debug.log 8 | .npmignore -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .yarn 2 | dist 3 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true 6 | } 7 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | yarn-path ".yarn/releases/yarn-1.22.17.cjs" 6 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: '@yarnpkg/plugin-interactive-tools' 6 | 7 | yarnPath: .yarn/releases/yarn-3.3.1.cjs 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Jack Edgson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crunker 2 | 3 | Simple way to merge, concatenate, play, export and download audio files with the Web Audio API. 4 | 5 | - No dependencies 6 | - Tiny 2kB gzipped 7 | - Written in Typescript 8 | 9 | [View online demos](https://jaggad.github.io/crunker/examples/) 10 | 11 | # Installation 12 | 13 | ```sh 14 | yarn add crunker 15 | ``` 16 | 17 | ```sh 18 | npm install crunker 19 | ``` 20 | 21 | # Example 22 | 23 | ```javascript 24 | let crunker = new Crunker(); 25 | 26 | crunker 27 | .fetchAudio('/song.mp3', '/another-song.mp3') 28 | .then((buffers) => { 29 | // => [AudioBuffer, AudioBuffer] 30 | return crunker.mergeAudio(buffers); 31 | }) 32 | .then((merged) => { 33 | // => AudioBuffer 34 | return crunker.export(merged, 'audio/mp3'); 35 | }) 36 | .then((output) => { 37 | // => {blob, element, url} 38 | crunker.download(output.blob); 39 | document.body.append(output.element); 40 | console.log(output.url); 41 | }) 42 | .catch((error) => { 43 | // => Error Message 44 | }); 45 | 46 | crunker.notSupported(() => { 47 | // Handle no browser support 48 | }); 49 | ``` 50 | 51 | # Condensed Example 52 | 53 | ```javascript 54 | let crunker = new Crunker(); 55 | 56 | crunker 57 | .fetchAudio('/voice.mp3', '/background.mp3') 58 | .then((buffers) => crunker.mergeAudio(buffers)) 59 | .then((merged) => crunker.export(merged, 'audio/mp3')) 60 | .then((output) => crunker.download(output.blob)) 61 | .catch((error) => { 62 | throw new Error(error); 63 | }); 64 | ``` 65 | 66 | # Input file Example 67 | 68 | ```javascript 69 | let crunker = new Crunker(); 70 | 71 | const onFileInputChange = async (target) => { 72 | const buffers = await crunker.fetchAudio(...target.files, '/voice.mp3', '/background.mp3'); 73 | }; 74 | 75 | ; 76 | ``` 77 | 78 | ## Other Examples 79 | 80 | - [Beat Builder Machine](examples/client/beatBuilder.html) 81 | 82 | # [Graphic Representation of Methods](https://github.com/jackedgson/crunker/issues/16) 83 | 84 | ## Merge 85 | 86 | ![merge](https://user-images.githubusercontent.com/12958674/88806278-968f0680-d186-11ea-9cb5-8ef2606ffcc7.png) 87 | 88 | ## Concat 89 | 90 | ![concat](https://user-images.githubusercontent.com/12958674/88806297-9d1d7e00-d186-11ea-8cd2-c64cb0324845.png) 91 | 92 | # Methods 93 | 94 | For more detailed API documentation, view the Typescript typings. 95 | 96 | ## new Crunker() 97 | 98 | Create a new instance of Crunker. 99 | You may optionally provide an object with a `sampleRate` key, but it will default to the same sample rate as the internal audio context, which is appropriate for your device. 100 | 101 | ## crunker.fetchAudio(songURL, anotherSongURL) 102 | 103 | Fetch one or more audio files.\ 104 | **Returns:** an array of audio buffers in the order they were fetched. 105 | 106 | ## crunker.mergeAudio(arrayOfBuffers); 107 | 108 | Merge two or more audio buffers.\ 109 | **Returns:** a single `AudioBuffer` object. 110 | 111 | ## crunker.concatAudio(arrayOfBuffers); 112 | 113 | Concatenate two or more audio buffers in the order specified.\ 114 | **Returns:** a single `AudioBuffer` object. 115 | 116 | ## crunker.padAudio(buffer, padStart, seconds); 117 | 118 | Pad the audio with silence, at the beginning, the end, or any specified points through the audio.\ 119 | **Returns:** a single `AudioBuffer` object. 120 | 121 | ## crunker.sliceAudio(buffer, start, end, fadeIn, fadeOut); 122 | 123 | Slice the audio to the specified range, removing any content outside the range. Optionally add a fade-in at the start and a fade-out at the end to avoid audible clicks. 124 | 125 | - **buffer:** The audio buffer to be trimmed. 126 | - **start:** The starting second from where the audio should begin. 127 | - **end:** The ending second where the audio should be trimmed. 128 | - **fadeIn:** (Optional) Number of seconds for the fade-in effect at the beginning. Default is `0`. 129 | - **fadeOut:** (Optional) Number of seconds for the fade-out effect at the end. Default is `0`. 130 | 131 | **Returns:** a single `AudioBuffer` object. 132 | 133 | ## crunker.export(buffer, type); 134 | 135 | Export an audio buffers with MIME type option.\ 136 | **Type:** e.g. `'audio/mp3', 'audio/wav', 'audio/ogg'`. 137 | **IMPORTANT**: the MIME type does **not** change the actual file format. It will always be a `WAVE` file under the hood.\ 138 | **Returns:** an object containing the blob object, url, and an audio element object. 139 | 140 | ## crunker.download(blob, filename); 141 | 142 | Automatically download an exported audio blob with optional filename.\ 143 | **Filename:** String **not** containing the .mp3, .wav, or .ogg file extension.\ 144 | **Returns:** the `HTMLAnchorElement` element used to simulate the automatic download. 145 | 146 | ## crunker.play(buffer); 147 | 148 | Starts playing the exported audio buffer in the background.\ 149 | **Returns:** the `HTMLAudioElement`. 150 | 151 | ## crunker.notSupported(callback); 152 | 153 | Execute custom code if Web Audio API is not supported by the users browser.\ 154 | **Returns:** The callback function. 155 | 156 | # Properties 157 | 158 | For more detailed API documentation, view the Typescript typings. 159 | 160 | ## crunker.context 161 | 162 | Access the [AudioContext](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext) used internally by a given Crunker.\ 163 | **Returns:** [AudioContext](https://developer.mozilla.org/en-US/docs/Web/API/AudioContext). 164 | 165 | # License 166 | 167 | MIT 168 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | This directory contains some examples of possible use-cases for crunker.js and to serve as a reference of how to apply it in various scenarios. Full source code and instructions on how to run them are included in each sub-directory. Alternately, hosted live demos are available for each at the links below. 4 | 5 | ## Live Demos 6 | 7 | - [Audio from Client](https://jaggad.github.io/crunker/examples/client/index.html) 8 | - [Audio from Server](https://jaggad.github.io/crunker/examples/server/index.html) 9 | - [Beat Builder](https://jaggad.github.io/crunker/examples/client/beatBuilder.html) 10 | -------------------------------------------------------------------------------- /examples/client/beatBuilder.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Crunker - Beat machine example 5 | 51 | 52 | 53 |

Crunker - Beat machine example

54 | 55 | 56 | -------------------------------------------------------------------------------- /examples/client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Audio from Client - Crunker Example 7 | 14 | 15 | 16 |
17 | 18 | 19 |
20 | 21 | 22 |
23 |
24 | 25 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /examples/server/1.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaggad/crunker/ccfc4438b08d24ace4cf85b2f21ebece4b35dacd/examples/server/1.mp3 -------------------------------------------------------------------------------- /examples/server/2.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jaggad/crunker/ccfc4438b08d24ace4cf85b2f21ebece4b35dacd/examples/server/2.mp3 -------------------------------------------------------------------------------- /examples/server/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Audio from Server - Crunker Example 7 | 8 | 9 | 10 | 11 | 12 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | config.set({ 3 | frameworks: ['mocha', 'chai'], 4 | files: ['test/test.bundle.js'], 5 | reporters: ['progress'], 6 | port: 9876, // karma web server port 7 | colors: true, 8 | browserDisconnectTimeout: 100000, 9 | logLevel: config.LOG_INFO, 10 | browsers: ['ChromeHeadless'], 11 | autoWatch: false, 12 | // singleRun: false, // Karma captures browsers, runs the tests and exits 13 | concurrency: Infinity, 14 | client: { 15 | mocha: { 16 | timeout: 10000, // 10 seconds - upped from 2 seconds 17 | }, 18 | }, 19 | }); 20 | }; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crunker", 3 | "version": "2.4.1", 4 | "description": "Simple way to merge or concatenate audio files with the Web Audio API.", 5 | "main": "dist/crunker.js", 6 | "types": "dist/crunker.d.ts", 7 | "directories": { 8 | "test": "test", 9 | "src": "src" 10 | }, 11 | "scripts": { 12 | "type-check": "tsc --noEmit", 13 | "type-check:watch": "yarn run type-check -- --watch", 14 | "build": "yarn run build:prod && yarn run build:types", 15 | "build:test": "webpack --config webpack.test.config.js --mode development", 16 | "build:prod": "yarn run build:prod:cjs && yarn run build:prod:esm", 17 | "build:prod:cjs": "webpack --config webpack.config.js --mode production", 18 | "build:prod:esm": "webpack --config webpack.esm.config.js --mode production", 19 | "build:types": "tsc --emitDeclarationOnly", 20 | "prepublishOnly": "yarn run build", 21 | "format": "prettier --write .", 22 | "format:check": "prettier --check .", 23 | "test": "NODE_ENV=development yarn run build:test && karma start --single-run --browsers ChromeHeadless karma.conf.js " 24 | }, 25 | "repository": { 26 | "type": "git", 27 | "url": "git+https://github.com/jaggad/crunker.git" 28 | }, 29 | "keywords": [ 30 | "web-audio-api", 31 | "es6", 32 | "merge", 33 | "concatenate", 34 | "append", 35 | "export", 36 | "download" 37 | ], 38 | "author": "Jack Edgson", 39 | "license": "MIT", 40 | "files": [ 41 | "LICENSE", 42 | "README.md", 43 | "dist" 44 | ], 45 | "bugs": { 46 | "url": "https://github.com/jaggad/crunker/issues" 47 | }, 48 | "homepage": "https://github.com/jaggad/crunker#readme", 49 | "devDependencies": { 50 | "@babel/cli": "^7.20.7", 51 | "@babel/core": "^7.20.12", 52 | "@babel/preset-env": "^7.20.2", 53 | "@babel/preset-typescript": "^7.18.6", 54 | "babel-loader": "^8.3.0", 55 | "babel-minify": "^0.5.2", 56 | "chai": "^4.3.7", 57 | "karma": "^6.4.1", 58 | "karma-chai": "^0.1.0", 59 | "karma-chrome-launcher": "^3.1.1", 60 | "karma-mocha": "^2.0.1", 61 | "mocha": "^9.2.2", 62 | "prettier": "^2.8.3", 63 | "terser-webpack-plugin": "^5.3.6", 64 | "typescript": "^4.9.5", 65 | "webpack": "^5.75.0", 66 | "webpack-cli": "^5.0.1" 67 | }, 68 | "publishConfig": { 69 | "registry": "https://registry.npmjs.org" 70 | }, 71 | "packageManager": "yarn@3.3.1" 72 | } 73 | -------------------------------------------------------------------------------- /src/crunker.ts: -------------------------------------------------------------------------------- 1 | export interface CrunkerConstructorOptions { 2 | /** 3 | * Sample rate for Crunker's internal audio context. 4 | * 5 | * @default 44100 6 | */ 7 | sampleRate: number; 8 | /** 9 | * Maximum number of concurrent network requests to use while fetching audio. Requests will be batched into groups of this size. 10 | * 11 | * Anything much higher than the default can cause issues in web browsers. You may wish to lower it for low-performance devices. 12 | * 13 | * @default 200 14 | */ 15 | concurrentNetworkRequests: number; 16 | } 17 | 18 | export type CrunkerInputTypes = string | File | Blob; 19 | 20 | /** 21 | * An exported Crunker audio object. 22 | */ 23 | export interface ExportedCrunkerAudio { 24 | blob: Blob; 25 | url: string; 26 | element: HTMLAudioElement; 27 | } 28 | 29 | /** 30 | * Crunker is the simple way to merge, concatenate, play, export and download audio files using the Web Audio API. 31 | */ 32 | export default class Crunker { 33 | private readonly _sampleRate: number; 34 | private readonly _concurrentNetworkRequests: number; 35 | private readonly _context: AudioContext; 36 | 37 | /** 38 | * Creates a new instance of Crunker with the provided options. 39 | * 40 | * If `sampleRate` is not defined, it will auto-select an appropriate sample rate 41 | * for the device being used. 42 | */ 43 | constructor({ sampleRate, concurrentNetworkRequests = 200 }: Partial = {}) { 44 | this._context = this._createContext(sampleRate); 45 | 46 | sampleRate ||= this._context.sampleRate; 47 | 48 | this._sampleRate = sampleRate; 49 | this._concurrentNetworkRequests = concurrentNetworkRequests; 50 | } 51 | 52 | /** 53 | * Creates Crunker's internal AudioContext. 54 | * 55 | * @internal 56 | */ 57 | private _createContext(sampleRate: number = 44_100): AudioContext { 58 | window.AudioContext = window.AudioContext || (window as any).webkitAudioContext || (window as any).mozAudioContext; 59 | return new AudioContext({ sampleRate }); 60 | } 61 | 62 | /** 63 | * 64 | * The internal AudioContext used by Crunker. 65 | */ 66 | get context(): AudioContext { 67 | return this._context; 68 | } 69 | 70 | /** 71 | * Asynchronously fetches multiple audio files and returns an array of AudioBuffers. 72 | * 73 | * Network requests are batched, and the size of these batches can be configured with the `concurrentNetworkRequests` option in the Crunker constructor. 74 | */ 75 | async fetchAudio(...filepaths: CrunkerInputTypes[]): Promise { 76 | const buffers: AudioBuffer[] = []; 77 | const groups = Math.ceil(filepaths.length / this._concurrentNetworkRequests); 78 | 79 | for (let i = 0; i < groups; i++) { 80 | const group = filepaths.slice(i * this._concurrentNetworkRequests, (i + 1) * this._concurrentNetworkRequests); 81 | buffers.push(...(await this._fetchAudio(...group))); 82 | } 83 | 84 | return buffers; 85 | } 86 | 87 | /** 88 | * Asynchronously fetches multiple audio files and returns an array of AudioBuffers. 89 | */ 90 | private async _fetchAudio(...filepaths: CrunkerInputTypes[]): Promise { 91 | return await Promise.all( 92 | filepaths.map(async (filepath) => { 93 | let buffer: ArrayBuffer; 94 | 95 | if (filepath instanceof File || filepath instanceof Blob) { 96 | buffer = await filepath.arrayBuffer(); 97 | } else { 98 | buffer = await fetch(filepath).then((response) => { 99 | if (response.headers.has('Content-Type') && !response.headers.get('Content-Type')!.includes('audio/')) { 100 | console.warn( 101 | `Crunker: Attempted to fetch an audio file, but its MIME type is \`${ 102 | response.headers.get('Content-Type')!.split(';')[0] 103 | }\`. We'll try and continue anyway. (file: "${filepath}")` 104 | ); 105 | } 106 | 107 | return response.arrayBuffer(); 108 | }); 109 | } 110 | 111 | return await this._context.decodeAudioData(buffer); 112 | }) 113 | ); 114 | } 115 | 116 | /** 117 | * Merges (layers) multiple AudioBuffers into a single AudioBuffer. 118 | * 119 | * **Visual representation:** 120 | * 121 | * ![](https://user-images.githubusercontent.com/12958674/88806278-968f0680-d186-11ea-9cb5-8ef2606ffcc7.png) 122 | */ 123 | mergeAudio(buffers: AudioBuffer[]): AudioBuffer { 124 | const output = this._context.createBuffer( 125 | this._maxNumberOfChannels(buffers), 126 | this._sampleRate * this._maxDuration(buffers), 127 | this._sampleRate 128 | ); 129 | 130 | buffers.forEach((buffer) => { 131 | for (let channelNumber = 0; channelNumber < buffer.numberOfChannels; channelNumber++) { 132 | const outputData = output.getChannelData(channelNumber); 133 | const bufferData = buffer.getChannelData(channelNumber); 134 | 135 | for (let i = buffer.getChannelData(channelNumber).length - 1; i >= 0; i--) { 136 | outputData[i] += bufferData[i]; 137 | } 138 | 139 | output.getChannelData(channelNumber).set(outputData); 140 | } 141 | }); 142 | 143 | return output; 144 | } 145 | 146 | /** 147 | * Concatenates multiple AudioBuffers into a single AudioBuffer. 148 | * 149 | * **Visual representation:** 150 | * 151 | * ![](https://user-images.githubusercontent.com/12958674/88806297-9d1d7e00-d186-11ea-8cd2-c64cb0324845.png) 152 | */ 153 | concatAudio(buffers: AudioBuffer[]): AudioBuffer { 154 | const output = this._context.createBuffer( 155 | this._maxNumberOfChannels(buffers), 156 | this._totalLength(buffers), 157 | this._sampleRate 158 | ); 159 | let offset = 0; 160 | 161 | buffers.forEach((buffer) => { 162 | for (let channelNumber = 0; channelNumber < buffer.numberOfChannels; channelNumber++) { 163 | output.getChannelData(channelNumber).set(buffer.getChannelData(channelNumber), offset); 164 | } 165 | 166 | offset += buffer.length; 167 | }); 168 | 169 | return output; 170 | } 171 | 172 | /** 173 | * Pads a specified AudioBuffer with silence from a specified start time, 174 | * for a specified length of time. 175 | * 176 | * Accepts float values as well as whole integers. 177 | * 178 | * @param buffer AudioBuffer to pad 179 | * @param padStart Time to start padding (in seconds) 180 | * @param seconds Duration to pad for (in seconds) 181 | */ 182 | padAudio(buffer: AudioBuffer, padStart: number = 0, seconds: number = 0): AudioBuffer { 183 | if (seconds === 0) return buffer; 184 | 185 | if (padStart < 0) throw new Error('Crunker: Parameter "padStart" in padAudio must be positive'); 186 | if (seconds < 0) throw new Error('Crunker: Parameter "seconds" in padAudio must be positive'); 187 | 188 | const updatedBuffer = this._context.createBuffer( 189 | buffer.numberOfChannels, 190 | Math.ceil(buffer.length + seconds * buffer.sampleRate), 191 | buffer.sampleRate 192 | ); 193 | 194 | for (let channelNumber = 0; channelNumber < buffer.numberOfChannels; channelNumber++) { 195 | const channelData = buffer.getChannelData(channelNumber); 196 | updatedBuffer 197 | .getChannelData(channelNumber) 198 | .set(channelData.subarray(0, Math.ceil(padStart * buffer.sampleRate) + 1), 0); 199 | 200 | updatedBuffer 201 | .getChannelData(channelNumber) 202 | .set( 203 | channelData.subarray(Math.ceil(padStart * buffer.sampleRate) + 2, updatedBuffer.length + 1), 204 | Math.ceil((padStart + seconds) * buffer.sampleRate) 205 | ); 206 | } 207 | 208 | return updatedBuffer; 209 | } 210 | 211 | /** 212 | * Slices an AudioBuffer from the specified start time to the end time, with optional fade in and out. 213 | * 214 | * @param buffer AudioBuffer to slice 215 | * @param start Start time (in seconds) 216 | * @param end End time (in seconds) 217 | * @param fadeIn Fade in duration (in seconds, default is 0) 218 | * @param fadeOut Fade out duration (in seconds, default is 0) 219 | */ 220 | sliceAudio(buffer: AudioBuffer, start: number, end: number, fadeIn: number = 0, fadeOut: number = 0): AudioBuffer { 221 | if (start >= end) throw new Error('Crunker: "start" time should be less than "end" time in sliceAudio method'); 222 | 223 | const length = Math.round((end - start) * this._sampleRate); 224 | const offset = Math.round(start * this._sampleRate); 225 | const newBuffer = this._context.createBuffer(buffer.numberOfChannels, length, this._sampleRate); 226 | 227 | for (let channel = 0; channel < buffer.numberOfChannels; channel++) { 228 | const inputData = buffer.getChannelData(channel); 229 | const outputData = newBuffer.getChannelData(channel); 230 | 231 | for (let i = 0; i < length; i++) { 232 | outputData[i] = inputData[offset + i]; 233 | 234 | // Apply fade in 235 | if (i < fadeIn * this._sampleRate) { 236 | outputData[i] *= i / (fadeIn * this._sampleRate); 237 | } 238 | 239 | // Apply fade out 240 | if (i > length - fadeOut * this._sampleRate) { 241 | outputData[i] *= (length - i) / (fadeOut * this._sampleRate); 242 | } 243 | } 244 | } 245 | 246 | return newBuffer; 247 | } 248 | 249 | /** 250 | * Plays the provided AudioBuffer in an AudioBufferSourceNode. 251 | */ 252 | play(buffer: AudioBuffer): AudioBufferSourceNode { 253 | const source = this._context.createBufferSource(); 254 | 255 | source.buffer = buffer; 256 | source.connect(this._context.destination); 257 | source.start(); 258 | 259 | return source; 260 | } 261 | 262 | /** 263 | * Exports the specified AudioBuffer to a Blob, Object URI and HTMLAudioElement. 264 | * 265 | * Note that changing the MIME type does not change the actual file format. The 266 | * file format will **always** be a WAVE file due to how audio is stored in the 267 | * browser. 268 | * 269 | * @param buffer Buffer to export 270 | * @param type MIME type (default: `audio/wav`) 271 | */ 272 | export(buffer: AudioBuffer, type: string = 'audio/wav'): ExportedCrunkerAudio { 273 | const recorded = this._interleave(buffer); 274 | const dataview = this._writeHeaders(recorded, buffer.numberOfChannels, buffer.sampleRate); 275 | const audioBlob = new Blob([dataview], { type }); 276 | 277 | return { 278 | blob: audioBlob, 279 | url: this._renderURL(audioBlob), 280 | element: this._renderAudioElement(audioBlob), 281 | }; 282 | } 283 | 284 | /** 285 | * Downloads the provided Blob. 286 | * 287 | * @param blob Blob to download 288 | * @param filename An optional file name to use for the download (default: `crunker`) 289 | */ 290 | download(blob: Blob, filename: string = 'crunker'): HTMLAnchorElement { 291 | const a = document.createElement('a'); 292 | 293 | a.style.display = 'none'; 294 | a.href = this._renderURL(blob); 295 | a.download = `${filename}.${blob.type.split('/')[1]}`; 296 | a.click(); 297 | 298 | return a; 299 | } 300 | 301 | /** 302 | * Executes a callback if the browser does not support the Web Audio API. 303 | * 304 | * Returns the result of the callback, or `undefined` if the Web Audio API is supported. 305 | * 306 | * @param callback callback to run if the browser does not support the Web Audio API 307 | */ 308 | notSupported(callback: () => T): T | undefined { 309 | return this._isSupported() ? undefined : callback(); 310 | } 311 | 312 | /** 313 | * Closes Crunker's internal AudioContext. 314 | */ 315 | close(): this { 316 | this._context.close(); 317 | return this; 318 | } 319 | 320 | /** 321 | * Returns the largest duration of the longest AudioBuffer. 322 | * 323 | * @internal 324 | */ 325 | private _maxDuration(buffers: AudioBuffer[]): number { 326 | return Math.max(...buffers.map((buffer) => buffer.duration)); 327 | } 328 | 329 | /** 330 | * Returns the largest number of channels in an array of AudioBuffers. 331 | * 332 | * @internal 333 | */ 334 | private _maxNumberOfChannels(buffers: AudioBuffer[]): number { 335 | return Math.max(...buffers.map((buffer) => buffer.numberOfChannels)); 336 | } 337 | 338 | /** 339 | * Returns the sum of the lengths of an array of AudioBuffers. 340 | * 341 | * @internal 342 | */ 343 | private _totalLength(buffers: AudioBuffer[]): number { 344 | return buffers.map((buffer) => buffer.length).reduce((a, b) => a + b, 0); 345 | } 346 | 347 | /** 348 | * Returns whether the browser supports the Web Audio API. 349 | * 350 | * @internal 351 | */ 352 | private _isSupported(): boolean { 353 | return 'AudioContext' in window || 'webkitAudioContext' in window || 'mozAudioContext' in window; 354 | } 355 | 356 | /** 357 | * Writes the WAV headers for the specified Float32Array. 358 | * 359 | * Returns a DataView containing the WAV headers and file content. 360 | * 361 | * @internal 362 | */ 363 | private _writeHeaders(buffer: Float32Array, numOfChannels: number, sampleRate: number): DataView { 364 | const bitDepth = 16; 365 | const bytesPerSample = bitDepth / 8; 366 | const sampleSize = numOfChannels * bytesPerSample; 367 | 368 | const fileHeaderSize = 8; 369 | const chunkHeaderSize = 36; 370 | const chunkDataSize = buffer.length * bytesPerSample; 371 | const chunkTotalSize = chunkHeaderSize + chunkDataSize; 372 | 373 | const arrayBuffer = new ArrayBuffer(fileHeaderSize + chunkTotalSize); 374 | const view = new DataView(arrayBuffer); 375 | 376 | this._writeString(view, 0, 'RIFF'); 377 | view.setUint32(4, chunkTotalSize, true); 378 | this._writeString(view, 8, 'WAVE'); 379 | this._writeString(view, 12, 'fmt '); 380 | view.setUint32(16, 16, true); 381 | view.setUint16(20, 1, true); 382 | view.setUint16(22, numOfChannels, true); 383 | view.setUint32(24, sampleRate, true); 384 | view.setUint32(28, sampleRate * sampleSize, true); 385 | view.setUint16(32, sampleSize, true); 386 | view.setUint16(34, bitDepth, true); 387 | this._writeString(view, 36, 'data'); 388 | view.setUint32(40, chunkDataSize, true); 389 | 390 | return this._floatTo16BitPCM(view, buffer, fileHeaderSize + chunkHeaderSize); 391 | } 392 | 393 | /** 394 | * Converts a Float32Array to 16-bit PCM. 395 | * 396 | * @internal 397 | */ 398 | private _floatTo16BitPCM(dataview: DataView, buffer: Float32Array, offset: number): DataView { 399 | for (let i = 0; i < buffer.length; i++, offset += 2) { 400 | const tmp = Math.max(-1, Math.min(1, buffer[i])); 401 | dataview.setInt16(offset, tmp < 0 ? tmp * 0x8000 : tmp * 0x7fff, true); 402 | } 403 | 404 | return dataview; 405 | } 406 | 407 | /** 408 | * Writes a string to a DataView at the specified offset. 409 | * 410 | * @internal 411 | */ 412 | private _writeString(dataview: DataView, offset: number, header: string): void { 413 | for (let i = 0; i < header.length; i++) { 414 | dataview.setUint8(offset + i, header.charCodeAt(i)); 415 | } 416 | } 417 | 418 | /** 419 | * Converts an AudioBuffer to a Float32Array. 420 | * 421 | * @internal 422 | */ 423 | private _interleave(input: AudioBuffer): Float32Array { 424 | if (input.numberOfChannels === 1) { 425 | // No need to interleave channels, just return single channel data to save performance and memory 426 | return input.getChannelData(0); 427 | } 428 | const channels = []; 429 | for (let i = 0; i < input.numberOfChannels; i++) { 430 | channels.push(input.getChannelData(i)); 431 | } 432 | const length = channels.reduce((prev, channelData) => prev + channelData.length, 0); 433 | const result = new Float32Array(length); 434 | 435 | let index = 0; 436 | let inputIndex = 0; 437 | 438 | // for 2 channels its like: [L[0], R[0], L[1], R[1], ... , L[n], R[n]] 439 | while (index < length) { 440 | channels.forEach((channelData) => { 441 | result[index++] = channelData[inputIndex]; 442 | }); 443 | 444 | inputIndex++; 445 | } 446 | 447 | return result; 448 | } 449 | 450 | /** 451 | * Creates an HTMLAudioElement whose source is the specified Blob. 452 | * 453 | * @internal 454 | */ 455 | private _renderAudioElement(blob: Blob): HTMLAudioElement { 456 | const audio = document.createElement('audio'); 457 | 458 | audio.controls = true; 459 | audio.src = this._renderURL(blob); 460 | 461 | return audio; 462 | } 463 | 464 | /** 465 | * Creates an Object URL for the specified Blob. 466 | * 467 | * @internal 468 | */ 469 | private _renderURL(blob: Blob): string { 470 | return (window.URL || window.webkitURL).createObjectURL(blob); 471 | } 472 | } 473 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | import Crunker from 'crunker'; 2 | 3 | describe('Crunker', () => { 4 | const url = 'https://unpkg.com/crunker@1.3.0/examples/server/2.mp3'; 5 | let audio, buffers; 6 | 7 | before(async () => { 8 | const testCrunk = new Crunker(); 9 | buffers = await testCrunk.fetchAudio(url, url); // avoid redownload 10 | }); 11 | 12 | beforeEach(() => { 13 | audio = new Crunker(); 14 | }); 15 | 16 | afterEach(() => { 17 | audio.close(); 18 | }); 19 | 20 | it('creates a context', () => { 21 | expect(audio._context).to.not.equal(null); 22 | }); 23 | 24 | it('returns internal context', () => { 25 | expect(audio.context).to.be.instanceOf(window.AudioContext); 26 | }); 27 | 28 | it('fetches a single audio file', async () => { 29 | const buffer = await audio.fetchAudio(url); 30 | expect(buffer[0]).to.have.property('sampleRate', 44100); 31 | }); 32 | 33 | it('fetches multiple audio files', async () => { 34 | const buffers = await audio.fetchAudio(url, url); 35 | buffers.map((buffer) => { 36 | expect(buffer).to.have.property('sampleRate', 44100); 37 | }); 38 | }); 39 | 40 | it('returns a single buffer when merging', () => { 41 | expect(audio.mergeAudio(buffers)).to.have.property('sampleRate', 44100); 42 | }); 43 | 44 | it('returns a single buffer when concatenating', () => { 45 | expect(audio.concatAudio(buffers)).to.have.property('sampleRate', 44100); 46 | }); 47 | 48 | it('uses correct length when concatenating', () => { 49 | expect(audio.concatAudio(buffers).duration.toFixed(2)).to.equal('16.30'); 50 | }); 51 | 52 | it('slices a buffer correctly', () => { 53 | expect(audio.sliceAudio(buffers[0], 0, 1, 0.1, 0.1).duration.toFixed(2)).to.equal('1.00'); 54 | }); 55 | 56 | it('exports an object', () => { 57 | const output = audio.export(buffers[0]); 58 | expect(output).to.not.equal(null); 59 | }); 60 | 61 | it('exports an object with blob', () => { 62 | expect(audio.export(buffers[0])).to.have.property('blob'); 63 | }); 64 | 65 | it('exports an object with audio element', () => { 66 | expect(audio.export(buffers[0])).to.have.property('element'); 67 | }); 68 | 69 | it('exports an object with url', () => { 70 | expect(audio.export(buffers[0])).to.have.property('url'); 71 | }); 72 | 73 | it('interleaves two channels', () => { 74 | const audioInput = buffers[0]; 75 | const interleaved = audio._interleave(audioInput); 76 | const left = audioInput.getChannelData(0); 77 | const right = audioInput.getChannelData(1); 78 | 79 | expect(interleaved.length).to.equal(left.length + right.length); 80 | expect([interleaved[0], interleaved[1]]).to.have.same.members([left[0], right[0]]); 81 | }); 82 | }); 83 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "dist", 7 | "strict": true, 8 | "allowSyntheticDefaultImports": true, 9 | "esModuleInterop": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const webpack = require('webpack'); 2 | const TerserPlugin = require('terser-webpack-plugin'); 3 | 4 | module.exports = { 5 | mode: 'production', 6 | context: __dirname + '/src', 7 | entry: './crunker.ts', 8 | output: { 9 | path: __dirname + '/dist', 10 | filename: 'crunker.js', 11 | library: 'Crunker', 12 | libraryTarget: 'umd', 13 | umdNamedDefine: true, 14 | globalObject: "typeof self !== 'undefined' ? self : this", 15 | }, 16 | devtool: 'source-map', 17 | module: { 18 | rules: [ 19 | { 20 | test: /\.ts$/, 21 | exclude: /node_modules/, 22 | loader: 'babel-loader', 23 | options: { 24 | presets: [ 25 | [ 26 | '@babel/preset-env', 27 | { exclude: ['@babel/plugin-transform-regenerator', '@babel/plugin-transform-async-to-generator'] }, 28 | ], 29 | '@babel/preset-typescript', 30 | ], 31 | }, 32 | }, 33 | ], 34 | }, 35 | optimization: { 36 | minimize: true, 37 | minimizer: [new TerserPlugin()], 38 | }, 39 | plugins: [ 40 | new webpack.DefinePlugin({ 41 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 42 | }), 43 | ], 44 | }; 45 | -------------------------------------------------------------------------------- /webpack.esm.config.js: -------------------------------------------------------------------------------- 1 | const config = require('./webpack.config.js'); 2 | 3 | config.experiments = { 4 | outputModule: true, 5 | }; 6 | 7 | config.output = { 8 | path: __dirname + '/dist', 9 | filename: 'crunker.esm.js', 10 | module: true, 11 | library: { 12 | type: 'module', 13 | }, 14 | }; 15 | 16 | module.exports = config; 17 | -------------------------------------------------------------------------------- /webpack.test.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); 3 | 4 | const testDir = `${__dirname}/test/`; 5 | 6 | module.exports = { 7 | context: __dirname, 8 | entry: `${testDir}/test.js`, 9 | devtool: 'inline-source-map', 10 | output: { 11 | path: testDir, 12 | filename: 'test.bundle.js', 13 | library: 'Crunker', 14 | libraryTarget: 'umd', 15 | umdNamedDefine: true, 16 | }, 17 | resolve: { 18 | alias: { 19 | crunker: path.resolve(__dirname, 'src/crunker.ts'), 20 | }, 21 | }, 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.(j|t)s$/, 26 | exclude: /node_modules/, 27 | loader: 'babel-loader', 28 | options: { 29 | presets: [ 30 | [ 31 | '@babel/preset-env', 32 | { exclude: ['@babel/plugin-transform-regenerator', '@babel/plugin-transform-async-to-generator'] }, 33 | ], 34 | '@babel/preset-typescript', 35 | ], 36 | }, 37 | }, 38 | ], 39 | }, 40 | plugins: [ 41 | new webpack.DefinePlugin({ 42 | 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV), 43 | }), 44 | ], 45 | }; 46 | --------------------------------------------------------------------------------