├── .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 | 
87 |
88 | ## Concat
89 |
90 | 
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 |
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 | * 
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 | * 
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 |
--------------------------------------------------------------------------------