├── src ├── AudioContext.ts ├── getUserMedia.ts ├── downloadBlob.ts ├── waveEncoder.ts ├── waveInterface.ts └── AudioRecorder.tsx ├── types └── dom.d.ts ├── webpack.config.js ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /src/AudioContext.ts: -------------------------------------------------------------------------------- 1 | export default AudioContext || webkitAudioContext; 2 | -------------------------------------------------------------------------------- /src/getUserMedia.ts: -------------------------------------------------------------------------------- 1 | export default navigator.mediaDevices ? 2 | navigator.mediaDevices.getUserMedia : 3 | ( 4 | navigator.getUserMedia || 5 | navigator.webkitGetUserMedia || 6 | navigator.mozGetUserMedia 7 | ); 8 | -------------------------------------------------------------------------------- /types/dom.d.ts: -------------------------------------------------------------------------------- 1 | interface Navigator { 2 | getUserMedia: NavigatorUserMedia['getUserMedia'] 3 | mozGetUserMedia: NavigatorUserMedia['getUserMedia']; 4 | webkitGetUserMedia: NavigatorUserMedia['getUserMedia']; 5 | } 6 | 7 | declare var webkitAudioContext: typeof AudioContext; 8 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | entry: './dist/AudioRecorder.js', 3 | output: { 4 | filename: './dist/AudioRecorder.min.js', 5 | library: 'AudioRecorder', 6 | libraryTarget: 'var' 7 | }, 8 | externals: { 9 | react: 'React', 10 | 'react-dom': 'ReactDOM' 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # This file is for unifying the coding style for different editors and IDEs 2 | # editorconfig.org 3 | root = true 4 | 5 | [*] 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = false 9 | insert_final_newline = true 10 | indent_style = space 11 | indent_size = 2 12 | 13 | [*.json] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Coverage tools 11 | lib-cov 12 | coverage 13 | coverage.html 14 | .cover* 15 | 16 | # Dependency directory 17 | node_modules 18 | 19 | # Example build directory 20 | example/dist 21 | .publish 22 | 23 | # Editor and other tmp files 24 | *.swp 25 | *.un~ 26 | *.iml 27 | *.ipr 28 | *.iws 29 | *.sublime-* 30 | .idea/ 31 | *.DS_Store 32 | .vscode 33 | dist 34 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "es6", 4 | "target": "es5", 5 | "jsx": "react", 6 | "declaration": true, 7 | "typeRoots": [ 8 | "typings", 9 | "node_modules/@types" 10 | ], 11 | "lib": [ 12 | "dom", 13 | "es2015" 14 | ], 15 | "outDir": "dist", 16 | "listEmittedFiles": true, 17 | "moduleResolution": "node" 18 | }, 19 | "include": [ 20 | "./src/**/*", 21 | "./types/**/*" 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /src/downloadBlob.ts: -------------------------------------------------------------------------------- 1 | // trigger a browser file download of binary data 2 | export default function downloadBlob(blob: Blob, filename: string) { 3 | const url = window.URL.createObjectURL(blob); 4 | const click = document.createEvent('Event'); 5 | click.initEvent('click', true, true); 6 | 7 | const link = document.createElement('A') as HTMLAnchorElement; 8 | link.href = url; 9 | link.download = filename; 10 | link.dispatchEvent(click); 11 | link.click(); 12 | return link; 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright 2018 Daniel Rouse and other react-audio-recorder contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-audio-recorder", 3 | "version": "2.1.0", 4 | "description": "Audio Recorder", 5 | "main": "dist/AudioRecorder.min.js", 6 | "types": "dist/AudioRecorder.d.ts", 7 | "module": "dist/AudioRecorder.js", 8 | "jsnext:main": "dist/AudioRecorder.js", 9 | "license": "MIT", 10 | "author": "Dan Rouse", 11 | "homepage": "https://github.com/danrouse/react-audio-recorder", 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/danrouse/react-audio-recorder.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/danrouse/react-audio-recorder/issues" 18 | }, 19 | "scripts": { 20 | "build": "tsc", 21 | "dev": "tsc -w", 22 | "bundle": "webpack -p", 23 | "test": "jest", 24 | "prepublish": "npm run build && npm run bundle" 25 | }, 26 | "keywords": [ 27 | "react", 28 | "react-component", 29 | "audio", 30 | "web-audio", 31 | "recording", 32 | "microphone" 33 | ], 34 | "peerDependencies": { 35 | "react": "^16.3.2" 36 | }, 37 | "devDependencies": { 38 | "@types/react": "^16.4.18", 39 | "jest": "^23.6.0", 40 | "react-dom": "^16.3.2", 41 | "ts-jest": "^23.10.4", 42 | "typescript": "^3.1.3", 43 | "webpack": "^4.8.1", 44 | "webpack-cli": "^3.1.2" 45 | }, 46 | "jest": { 47 | "transform": { 48 | "^.+\\.tsx?$": "ts-jest" 49 | }, 50 | "testRegex": "(/test/.*|\\.(test|spec))\\.(jsx?|tsx?)$", 51 | "moduleFileExtensions": [ 52 | "ts", 53 | "tsx", 54 | "js", 55 | "jsx" 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/waveEncoder.ts: -------------------------------------------------------------------------------- 1 | function writeUTFBytes(dataview: DataView, offset: number, string: string) { 2 | for (let i = 0; i < string.length; i++) { 3 | dataview.setUint8(offset + i, string.charCodeAt(i)); 4 | } 5 | } 6 | 7 | function mergeBuffers(buffer: Float32Array[], length): Float64Array { 8 | const result = new Float64Array(length); 9 | let offset = 0; 10 | for (let i = 0; i < buffer.length; i++) { 11 | const inner = buffer[i]; 12 | result.set(inner, offset); 13 | offset += inner.length; 14 | } 15 | return result; 16 | } 17 | 18 | function interleave(left: Float64Array, right: Float64Array): Float64Array { 19 | const length = left.length + right.length; 20 | const result = new Float64Array(length); 21 | let inputIndex = 0; 22 | for (let i = 0; i < length; ) { 23 | result[i++] = left[inputIndex]; 24 | result[i++] = right[inputIndex]; 25 | inputIndex++; 26 | } 27 | return result; 28 | } 29 | 30 | export default function encodeWAV( 31 | buffers: Float32Array[][], 32 | bufferLength: number, 33 | sampleRate: number, 34 | volume: number = 1 35 | ): Blob { 36 | const left = mergeBuffers(buffers[0], bufferLength); 37 | const right = mergeBuffers(buffers[1], bufferLength); 38 | const interleaved = interleave(left, right); 39 | const buffer = new ArrayBuffer(44 + interleaved.length * 2); 40 | const view = new DataView(buffer); 41 | 42 | writeUTFBytes(view, 0, 'RIFF'); 43 | view.setUint32(4, 44 + interleaved.length * 2, true); 44 | writeUTFBytes(view, 8, 'WAVE'); 45 | writeUTFBytes(view, 12, 'fmt '); 46 | view.setUint32(16, 16, true); 47 | view.setUint16(20, 1, true); 48 | view.setUint16(22, 2, true); 49 | view.setUint32(24, sampleRate, true); 50 | view.setUint32(28, sampleRate * 4, true); 51 | view.setUint16(32, 4, true); 52 | view.setUint16(34, 16, true); 53 | 54 | writeUTFBytes(view, 36, 'data'); 55 | view.setUint32(40, interleaved.length * 2, true); 56 | 57 | interleaved.forEach((sample, index) => { 58 | view.setInt16(44 + (index * 2), sample * (0x7fff * volume), true); 59 | }); 60 | 61 | return new Blob([view], { type: 'audio/wav' }); 62 | } 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Audio Recorder 2 | 3 | A React Component using the Web Audio API to record, save, and play audio. 4 | 5 | 6 | ## Demo & Examples 7 | 8 | Live demo: [danrouse.github.io/react-audio-recorder](https://danrouse.github.io/react-audio-recorder/) 9 | 10 | 11 | ## Installation 12 | 13 | The easiest way to use react-audio-recorder is to install it from NPM and include it in your own React build process (using [Webpack](http://webpack.js.org/), [Browserify](http://browserify.org), etc). 14 | 15 | You can also use the standalone build by including `dist/AudioRecorder.min.js` in your page. If you use this, make sure you have already included React, and it is available as a global variable. 16 | 17 | ``` 18 | npm install react-audio-recorder --save 19 | ``` 20 | 21 | 22 | ## Usage 23 | 24 | The `` component can be instantiated with no properties to act as a simple client-side recorder/downloader. `onChange` is called when a recording is finished, with the audio data passed as a blob. 25 | 26 | ``` 27 | import React as * from 'react'; 28 | import AudioRecorder from 'react-audio-recorder'; 29 | 30 | 31 | ``` 32 | 33 | For more detailed usage examples, see the [live demo](http://kremonte.github.io/react-audio-recorder/). 34 | 35 | ### Properties 36 | property|type|default|Description 37 | ----|----|-------|----------- 38 | initialAudio|Blob|An initial Blob of audio data 39 | downloadable|boolean|`true`|Whether to show a download button 40 | loop|boolean|`false`|Whether to loop audio playback 41 | filename|string|`'output.wav'`|Downloaded file name 42 | className|string|`''`|CSS class name on the container element 43 | style|Object|`{}`|Inline styles on the container element 44 | onAbort|`() => void`||Callback when playback is stopped 45 | onChange|`(AudioRecorderChangeEvent) => void`||Callback when the recording buffer is modified 46 | onEnded|`() => void`||Callback when playback completes on its own 47 | onPause|`() => void`||(NYI) 48 | onPlay|`() => void`||Callback when playback begins 49 | onRecordStart|`() => void`||Callback when recording begins 50 | playLabel|string|'🔊 Play'|Button label 51 | playingLabel|string|'❚❚ Playing'|Button label 52 | recordLabel|string|'● Record'|Button label 53 | recordingLabel|string|'● Recording'|Button label 54 | removeLabel|string|'✖ Remove'|Button label 55 | downloadLabel|string|'💾 Save'|Button label 56 | 57 | ### Notes 58 | 59 | This component is intended for use with short sounds only, such as speech samples and sound effects. The WAV encoder is not offloaded to a service worker, to make this component more portable. It is not space efficient either, recording at 1411kbps (16 bit stereo), so long recordings will drain the system of memory. 60 | 61 | ### Compatibility 62 | 63 | Because of its usage of the Web Audio API, react-audio-recorder is not compatible with any version of Internet Explorer (Edge is compatible). 64 | 65 | 66 | ## Development 67 | 68 | To use the typescript watcher, run `npm run dev`. 69 | -------------------------------------------------------------------------------- /src/waveInterface.ts: -------------------------------------------------------------------------------- 1 | import encodeWAV from './waveEncoder'; 2 | import getUserMedia from './getUserMedia'; 3 | import AudioContext from './AudioContext'; 4 | 5 | export default class WAVEInterface { 6 | static audioContext = new AudioContext(); 7 | static bufferSize = 2048; 8 | 9 | playbackNode: AudioBufferSourceNode; 10 | recordingNodes: AudioNode[] = []; 11 | recordingStream: MediaStream; 12 | buffers: Float32Array[][]; // one buffer for each channel L,R 13 | encodingCache?: Blob; 14 | 15 | get bufferLength() { return this.buffers[0].length * WAVEInterface.bufferSize; } 16 | get audioDuration() { return this.bufferLength / WAVEInterface.audioContext.sampleRate; } 17 | get audioData() { 18 | return this.encodingCache || encodeWAV(this.buffers, this.bufferLength, WAVEInterface.audioContext.sampleRate); 19 | } 20 | 21 | startRecording() { 22 | return new Promise((resolve, reject) => { 23 | getUserMedia({ audio: true }, (stream) => { 24 | const { audioContext } = WAVEInterface; 25 | const recGainNode = audioContext.createGain(); 26 | const recSourceNode = audioContext.createMediaStreamSource(stream); 27 | const recProcessingNode = audioContext.createScriptProcessor(WAVEInterface.bufferSize, 2, 2); 28 | if (this.encodingCache) this.encodingCache = null; 29 | 30 | recProcessingNode.onaudioprocess = (event) => { 31 | if (this.encodingCache) this.encodingCache = null; 32 | // save left and right buffers 33 | for (let i = 0; i < 2; i++) { 34 | const channel = event.inputBuffer.getChannelData(i); 35 | this.buffers[i].push(new Float32Array(channel)); 36 | } 37 | }; 38 | 39 | recSourceNode.connect(recGainNode); 40 | recGainNode.connect(recProcessingNode); 41 | recProcessingNode.connect(audioContext.destination); 42 | 43 | this.recordingStream = stream; 44 | this.recordingNodes.push(recSourceNode, recGainNode, recProcessingNode); 45 | resolve(stream); 46 | }, (err) => { 47 | reject(err); 48 | }); 49 | }); 50 | } 51 | 52 | stopRecording() { 53 | if (this.recordingStream) { 54 | this.recordingStream.getTracks()[0].stop(); 55 | delete this.recordingStream; 56 | } 57 | for (let i in this.recordingNodes) { 58 | this.recordingNodes[i].disconnect(); 59 | delete this.recordingNodes[i]; 60 | } 61 | } 62 | 63 | startPlayback(loop: boolean = false, onended: () => void) { 64 | return new Promise((resolve, reject) => { 65 | const reader = new FileReader(); 66 | reader.readAsArrayBuffer(this.audioData); 67 | reader.onloadend = () => { 68 | WAVEInterface.audioContext.decodeAudioData(reader.result as ArrayBuffer, (buffer) => { 69 | const source = WAVEInterface.audioContext.createBufferSource(); 70 | source.buffer = buffer; 71 | source.connect(WAVEInterface.audioContext.destination); 72 | source.loop = loop; 73 | source.start(0); 74 | source.onended = onended; 75 | this.playbackNode = source; 76 | resolve(source); 77 | }); 78 | }; 79 | }); 80 | } 81 | 82 | stopPlayback() { 83 | this.playbackNode.stop(); 84 | } 85 | 86 | reset() { 87 | if (this.playbackNode) { 88 | this.playbackNode.stop(); 89 | this.playbackNode.disconnect(0); 90 | delete this.playbackNode; 91 | } 92 | this.stopRecording(); 93 | this.buffers = [[], []]; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/AudioRecorder.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import WAVEInterface from './waveInterface'; 3 | import downloadBlob from './downloadBlob'; 4 | 5 | interface AudioRecorderChangeEvent { 6 | duration: number, 7 | audioData?: Blob, 8 | } 9 | interface AudioRecorderProps { 10 | initialAudio?: Blob, 11 | downloadable?: boolean, 12 | loop?: boolean, 13 | filename?: string, 14 | className?: string, 15 | style?: Object, 16 | 17 | onAbort?: () => void, 18 | onChange?: (AudioRecorderChangeEvent) => void, 19 | onEnded?: () => void, 20 | onPause?: () => void, 21 | onPlay?: () => void, 22 | onRecordStart?: () => void, 23 | 24 | playLabel?: string, 25 | playingLabel?: string, 26 | recordLabel?: string, 27 | recordingLabel?: string, 28 | removeLabel?: string, 29 | downloadLabel?: string, 30 | }; 31 | 32 | interface AudioRecorderState { 33 | isRecording: boolean, 34 | isPlaying: boolean, 35 | audioData?: Blob 36 | }; 37 | 38 | export default class AudioRecorder extends React.Component { 39 | waveInterface = new WAVEInterface(); 40 | 41 | state: AudioRecorderState = { 42 | isRecording: false, 43 | isPlaying: false, 44 | audioData: this.props.initialAudio 45 | }; 46 | 47 | static defaultProps = { 48 | loop: false, 49 | downloadable: true, 50 | className: '', 51 | style: {}, 52 | filename: 'output.wav', 53 | playLabel: '🔊 Play', 54 | playingLabel: '❚❚ Playing', 55 | recordLabel: '● Record', 56 | recordingLabel: '● Recording', 57 | removeLabel: '✖ Remove', 58 | downloadLabel: '\ud83d\udcbe Save' // unicode floppy disk 59 | }; 60 | 61 | componentWillReceiveProps(nextProps) { 62 | // handle new initialAudio being passed in 63 | if ( 64 | nextProps.initialAudio && 65 | nextProps.initialAudio !== this.props.initialAudio && 66 | this.state.audioData && 67 | nextProps.initialAudio !== this.state.audioData 68 | ) { 69 | this.waveInterface.reset(); 70 | this.setState({ 71 | audioData: nextProps.initialAudio, 72 | isPlaying: false, 73 | isRecording: false, 74 | }); 75 | } 76 | } 77 | 78 | componentWillMount() { this.waveInterface.reset(); } 79 | componentWillUnmount() { this.waveInterface.reset(); } 80 | 81 | startRecording() { 82 | if (!this.state.isRecording) { 83 | this.waveInterface.startRecording() 84 | .then(() => { 85 | this.setState({ isRecording: true }); 86 | if (this.props.onRecordStart) this.props.onRecordStart(); 87 | }) 88 | .catch((err) => { throw err; }); 89 | } 90 | } 91 | 92 | stopRecording() { 93 | this.waveInterface.stopRecording(); 94 | 95 | this.setState({ 96 | isRecording: false, 97 | audioData: this.waveInterface.audioData 98 | }); 99 | 100 | if (this.props.onChange) { 101 | this.props.onChange({ 102 | duration: this.waveInterface.audioDuration, 103 | audioData: this.waveInterface.audioData 104 | }); 105 | } 106 | } 107 | 108 | startPlayback() { 109 | if (!this.state.isPlaying) { 110 | this.waveInterface.startPlayback(this.props.loop, this.onAudioEnded).then(() => { 111 | this.setState({ isPlaying: true }); 112 | if (this.props.onPlay) this.props.onPlay(); 113 | }); 114 | } 115 | } 116 | 117 | stopPlayback() { 118 | this.waveInterface.stopPlayback(); 119 | this.setState({ isPlaying: false }); 120 | if (this.props.onAbort) this.props.onAbort(); 121 | } 122 | 123 | onAudioEnded = () => { 124 | this.setState({ isPlaying: false }); 125 | if (this.props.onEnded) this.props.onEnded(); 126 | }; 127 | 128 | onRemoveClick = () => { 129 | this.waveInterface.reset(); 130 | if (this.state.audioData && this.props.onChange) this.props.onChange({ duration: 0, audioData: null }); 131 | this.setState({ 132 | isPlaying: false, 133 | isRecording: false, 134 | audioData: null, 135 | }); 136 | }; 137 | 138 | onDownloadClick = () => downloadBlob(this.state.audioData, this.props.filename); 139 | 140 | onButtonClick = (event: React.SyntheticEvent) => { 141 | if (this.state.audioData) { 142 | if (this.state.isPlaying) { 143 | this.stopPlayback(); 144 | event.preventDefault(); 145 | } else { 146 | this.startPlayback(); 147 | } 148 | } else { 149 | if (this.state.isRecording) { 150 | this.stopRecording(); 151 | } else { 152 | this.startRecording(); 153 | } 154 | } 155 | }; 156 | 157 | render() { 158 | return ( 159 |
160 | 176 | {this.state.audioData && 177 | 183 | } 184 | {this.state.audioData && this.props.downloadable && 185 | 191 | } 192 |
193 | ); 194 | } 195 | } 196 | --------------------------------------------------------------------------------