├── .htaccess ├── README.md ├── app.js ├── index.html ├── style.css ├── vmsg.css ├── vmsg.js └── vmsg.wasm /.htaccess: -------------------------------------------------------------------------------- 1 | AddType application/wasm .wasm -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Plain HTML/JS audio recording demo using vmsg 2 | A simple HTML5/JS demo that uses [vmsg](https://github.com/Kagami/vmsg) to record mp3 audio in the browser. Works on both mobile - including Safari 11 - and desktop. 3 | 4 | As opposed to earlier JavaScript mp3 encoding solutions, vmsg uses a faster WebAssembly version of the latest LAME mp3 encoder ([3.100](https://svn.code.sf.net/p/lame/svn/trunk/lame/doc/html/history.html) from october 2017). 5 | 6 | All the vmsg demos are React based so I thought I'd do a plain HTML/JS one. I've taken great care to make the demo as easy to use on mobile and desktop. 7 | 8 | Live demo: [https://addpipe.com/simple-vmsg-demo/](https://addpipe.com/simple-vmsg-demo/) 9 | 10 | Blog post: https://blog.addpipe.com/recording-mp3-audio-in-html5-using-vmsg-a-webassembly-library-based-on-lame/ 11 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | import { record } from "./vmsg.js"; 2 | 3 | let recordButton = document.getElementById("recordButton"); 4 | recordButton.onclick = function() { 5 | record({wasmURL: "vmsg.wasm"}).then(blob => { 6 | console.log("Recorded MP3", blob); 7 | var url = URL.createObjectURL(blob); 8 | var preview = document.createElement('audio'); 9 | preview.controls = true;preview.src = url; 10 | document.body.appendChild(preview); 11 | }); 12 | }; -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Simple vmsg audio recording demo - addpipe.com 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |

Simple vmsg demo

14 |

Made by the Pipe Video Recording Platform

15 |

This demo uses vmsg to record and compress audio to .mp3. vmsg is a recent audio recording and encoding library based on a WebAssembly version of the latest LAME mp3 encoder. 16 |

17 |

Check out the code on GitHub and our blog post on recording mp3 audio in HTML5 using vmsg. 18 |

19 |
20 | 21 |
22 |
23 | 29 | 30 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | a { 2 | color: #337ab7; 3 | } 4 | 5 | p { 6 | margin-top: 1rem; 7 | } 8 | 9 | a:hover { 10 | color:#23527c; 11 | } 12 | 13 | a:visited { 14 | color: #8d75a3; 15 | } 16 | 17 | body { 18 | line-height: 1.5; 19 | font-family: sans-serif; 20 | word-wrap: break-word; 21 | overflow-wrap: break-word; 22 | color:black; 23 | margin:2em; 24 | } 25 | 26 | h1 { 27 | text-decoration: underline red; 28 | text-decoration-thickness: 3px; 29 | text-underline-offset: 6px; 30 | font-size: 220%; 31 | font-weight: bold; 32 | } 33 | 34 | h2 { 35 | font-weight: bold; 36 | color: #005A9C; 37 | font-size: 140%; 38 | text-transform: uppercase; 39 | } 40 | 41 | #controls { 42 | display: flex; 43 | margin-top: 2rem; 44 | max-width: 28em; 45 | } 46 | 47 | button { 48 | flex-grow: 1; 49 | height: 3.5rem; 50 | min-width: 2rem; 51 | border: none; 52 | border-radius: 0.15rem; 53 | background: #ed341d; 54 | margin-left: 2px; 55 | box-shadow: inset 0 -0.15rem 0 rgba(0, 0, 0, 0.2); 56 | cursor: pointer; 57 | display: flex; 58 | justify-content: center; 59 | align-items: center; 60 | color:#ffffff; 61 | font-weight: bold; 62 | font-size: 1.5rem; 63 | } 64 | 65 | button:hover, button:focus { 66 | outline: none; 67 | background: #c72d1c; 68 | } 69 | 70 | button::-moz-focus-inner { 71 | border: 0; 72 | } 73 | 74 | button:active { 75 | box-shadow: inset 0 1px 0 rgba(0, 0, 0, 0.2); 76 | line-height: 3rem; 77 | } 78 | 79 | button:disabled { 80 | pointer-events: none; 81 | background: lightgray; 82 | } 83 | 84 | button:first-child { 85 | margin-left: 0; 86 | } 87 | 88 | audio{ 89 | display: block; 90 | width: 100%; 91 | margin-top: 0.2rem; 92 | max-width: 28em; 93 | } 94 | 95 | li{ 96 | list-style: none; 97 | margin-bottom: 1rem; 98 | } 99 | 100 | #formats { 101 | margin-top: 0.5rem; 102 | font-size: 80%; 103 | } -------------------------------------------------------------------------------- /vmsg.css: -------------------------------------------------------------------------------- 1 | .vmsg-backdrop { 2 | position: fixed; 3 | left: 0; 4 | right: 0; 5 | top: 0; 6 | bottom: 0; 7 | display: flex; 8 | background: rgba(0,0,0,.7); 9 | align-items: center; 10 | justify-content: center; 11 | } 12 | 13 | .vmsg-popup { 14 | box-sizing: border-box; 15 | width: 250px; 16 | padding: 10px; 17 | border-radius: 4px; 18 | background: #e4e1e5; 19 | box-shadow: 1px 1px 4px 0 rgba(59,26,84,.6); 20 | display: flex; 21 | flex-direction: column; 22 | justify-content: center; 23 | font-family: Helvetica,sans-serif; 24 | font-size: 14px; 25 | line-height: 1.4; 26 | color: #0a0a0a; 27 | } 28 | 29 | .vmsg-progress { 30 | width: 40%; 31 | margin: 0 auto; 32 | display: flex; 33 | justify-content: space-between; 34 | } 35 | 36 | .vmsg-progress-dot { 37 | width: 15px; 38 | height: 15px; 39 | border-radius: 50%; 40 | animation: vmsg-progress 1s linear infinite; 41 | } 42 | .vmsg-progress-dot:nth-child(2) { 43 | animation-delay: -0.8s; 44 | } 45 | .vmsg-progress-dot:nth-child(3) { 46 | animation-delay: -0.6s; 47 | } 48 | @keyframes vmsg-progress { 49 | 0%, 60%, 100% { 50 | background: none; 51 | } 52 | 30% { 53 | background: #9e85ad; 54 | } 55 | } 56 | 57 | .vmsg-error { 58 | font-weight: bold; 59 | text-align: center; 60 | } 61 | 62 | .vmsg-record-row { 63 | display: flex; 64 | justify-content: space-between; 65 | } 66 | 67 | .vmsg-button { 68 | min-width: 40px; 69 | line-height: 30px; 70 | padding: 0; 71 | background: transparent; 72 | border: 1px solid #ccc; 73 | font-family: Helvetica,sans-serif; 74 | cursor: pointer; 75 | outline: none; 76 | user-select: none; 77 | } 78 | .vmsg-button:disabled { 79 | cursor: default; 80 | color: #999; 81 | } 82 | .vmsg-button:not(:disabled):hover { 83 | border-color: #9e85ad; 84 | } 85 | .vmsg-button::-moz-focus-inner { 86 | border: 0; 87 | } 88 | .vmsg-record-button { 89 | font-size: 30px; 90 | color: #f00; 91 | } 92 | .vmsg-stop-button { 93 | font-size: 25px; 94 | color: #000; 95 | } 96 | .vmsg-save-button { 97 | font-size: 25px; 98 | color: #090; 99 | } 100 | 101 | .vmsg-timer { 102 | line-height: 32px; 103 | font-weight: bold; 104 | color: #333; 105 | cursor: pointer; 106 | user-select: none; 107 | } 108 | 109 | .vmsg-slider-wrapper { 110 | position: relative; 111 | margin-top: 3px; 112 | } 113 | .vmsg-slider-wrapper::after { 114 | position: absolute; 115 | left: 0; 116 | right: 0; 117 | top: 0; 118 | bottom: 0; 119 | line-height: 14px; 120 | text-align: center; 121 | color: #999; 122 | pointer-events: none; 123 | } 124 | .vmsg-pitch-slider-wrapper::after { 125 | content: "pitch"; 126 | } 127 | .vmsg-gain-slider-wrapper::after { 128 | content: "gain"; 129 | } 130 | .vmsg-slider { 131 | display: block; 132 | width: 100%; 133 | height: 16px; 134 | margin: 0; 135 | padding: 0; 136 | outline: none; 137 | background: none; 138 | -webkit-appearance: none; 139 | } 140 | .vmsg-slider::-moz-focus-outer { 141 | border: 0; 142 | } 143 | .vmsg-slider::-webkit-slider-runnable-track { 144 | box-sizing: border-box; 145 | height: 16px; 146 | background: none; 147 | border: 1px solid #ccc; 148 | } 149 | .vmsg-slider::-moz-range-track { 150 | box-sizing: border-box; 151 | height: 16px; 152 | background: none; 153 | border: 1px solid #ccc; 154 | } 155 | .vmsg-slider::-ms-track { 156 | box-sizing: border-box; 157 | height: 16px; 158 | background: none; 159 | border: 1px solid #ccc; 160 | } 161 | .vmsg-slider::-webkit-slider-thumb { 162 | width: 39px; 163 | height: 14px; 164 | background: #ccc; 165 | cursor: pointer; 166 | -webkit-appearance: none; 167 | } 168 | .vmsg-slider::-moz-range-thumb { 169 | width: 40px; 170 | height: 14px; 171 | background: #ccc; 172 | border: none; 173 | border-radius: 0; 174 | cursor: pointer; 175 | } 176 | .vmsg-slider::-ms-thumb { 177 | width: 39px; 178 | height: 14px; 179 | background: #ccc; 180 | cursor: pointer; 181 | } 182 | .vmsg-slider::-webkit-slider-thumb:hover { 183 | background: #999; 184 | } 185 | .vmsg-slider::-moz-range-thumb:hover { 186 | background: #999; 187 | } 188 | .vmsg-slider::-ms-thumb:hover { 189 | background: #999; 190 | } 191 | .vmsg-slider::-ms-tooltip { 192 | display: none; 193 | } 194 | -------------------------------------------------------------------------------- /vmsg.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | function pad2(n) { 4 | n |= 0; 5 | return n < 10 ? `0${n}` : `${Math.min(n, 99)}`; 6 | } 7 | 8 | function inlineWorker() { 9 | // TODO(Kagami): Cache compiled module in IndexedDB? It works in FF 10 | // and Edge, see: https://github.com/mdn/webassembly-examples/issues/4 11 | // Though gzipped WASM module currently weights ~70kb so it should be 12 | // perfectly cached by the browser itself. 13 | function fetchAndInstantiate(url, imports) { 14 | if (!WebAssembly.instantiateStreaming) return fetchAndInstantiateFallback(url, imports); 15 | const req = fetch(url, {credentials: "same-origin"}); 16 | return WebAssembly.instantiateStreaming(req, imports).catch(err => { 17 | // https://github.com/Kagami/vmsg/issues/11 18 | if (err.message && err.message.indexOf("Argument 0 must be provided and must be a Response") > 0) { 19 | return fetchAndInstantiateFallback(url, imports); 20 | } else { 21 | throw err; 22 | } 23 | }); 24 | } 25 | 26 | function fetchAndInstantiateFallback(url, imports) { 27 | return new Promise((resolve, reject) => { 28 | const req = new XMLHttpRequest(); 29 | req.open("GET", url); 30 | req.responseType = "arraybuffer"; 31 | req.onload = () => { 32 | resolve(WebAssembly.instantiate(req.response, imports)); 33 | }; 34 | req.onerror = reject; 35 | req.send(); 36 | }); 37 | } 38 | 39 | // Must be in sync with emcc settings! 40 | const TOTAL_STACK = 5 * 1024 * 1024; 41 | const TOTAL_MEMORY = 16 * 1024 * 1024; 42 | const WASM_PAGE_SIZE = 64 * 1024; 43 | let memory = null; 44 | let dynamicTop = TOTAL_STACK; 45 | // TODO(Kagami): Grow memory? 46 | function sbrk(increment) { 47 | const oldDynamicTop = dynamicTop; 48 | dynamicTop += increment; 49 | return oldDynamicTop; 50 | } 51 | // TODO(Kagami): LAME calls exit(-1) on internal error. Would be nice 52 | // to provide custom DEBUGF/ERRORF for easier debugging. Currenty 53 | // those functions do nothing. 54 | function exit(status) { 55 | postMessage({type: "internal-error", data: status}); 56 | } 57 | 58 | let FFI = null; 59 | let ref = null; 60 | let pcm_l = null; 61 | function vmsg_init(rate) { 62 | ref = FFI.vmsg_init(rate); 63 | if (!ref) return false; 64 | const pcm_l_ref = new Uint32Array(memory.buffer, ref, 1)[0]; 65 | pcm_l = new Float32Array(memory.buffer, pcm_l_ref); 66 | return true; 67 | } 68 | function vmsg_encode(data) { 69 | pcm_l.set(data); 70 | return FFI.vmsg_encode(ref, data.length) >= 0; 71 | } 72 | function vmsg_flush() { 73 | if (FFI.vmsg_flush(ref) < 0) return null; 74 | const mp3_ref = new Uint32Array(memory.buffer, ref + 4, 1)[0]; 75 | const size = new Uint32Array(memory.buffer, ref + 8, 1)[0]; 76 | const mp3 = new Uint8Array(memory.buffer, mp3_ref, size); 77 | const blob = new Blob([mp3], {type: "audio/mpeg"}); 78 | FFI.vmsg_free(ref); 79 | ref = null; 80 | pcm_l = null; 81 | return blob; 82 | } 83 | 84 | // https://github.com/brion/min-wasm-fail 85 | function testSafariWebAssemblyBug() { 86 | const bin = new Uint8Array([0,97,115,109,1,0,0,0,1,6,1,96,1,127,1,127,3,2,1,0,5,3,1,0,1,7,8,1,4,116,101,115,116,0,0,10,16,1,14,0,32,0,65,1,54,2,0,32,0,40,2,0,11]); 87 | const mod = new WebAssembly.Module(bin); 88 | const inst = new WebAssembly.Instance(mod, {}); 89 | // test storing to and loading from a non-zero location via a parameter. 90 | // Safari on iOS 11.2.5 returns 0 unexpectedly at non-zero locations 91 | return (inst.exports.test(4) !== 0); 92 | } 93 | 94 | onmessage = (e) => { 95 | const msg = e.data; 96 | switch (msg.type) { 97 | case "init": 98 | const { wasmURL, shimURL } = msg.data; 99 | Promise.resolve().then(() => { 100 | if (self.WebAssembly && !testSafariWebAssemblyBug()) { 101 | delete self.WebAssembly; 102 | } 103 | if (!self.WebAssembly) { 104 | importScripts(shimURL); 105 | } 106 | memory = new WebAssembly.Memory({ 107 | initial: TOTAL_MEMORY / WASM_PAGE_SIZE, 108 | maximum: TOTAL_MEMORY / WASM_PAGE_SIZE, 109 | }); 110 | return { 111 | memory: memory, 112 | pow: Math.pow, 113 | exit: exit, 114 | powf: Math.pow, 115 | exp: Math.exp, 116 | sqrtf: Math.sqrt, 117 | cos: Math.cos, 118 | log: Math.log, 119 | sin: Math.sin, 120 | sbrk: sbrk, 121 | }; 122 | }).then(Runtime => { 123 | return fetchAndInstantiate(wasmURL, {env: Runtime}) 124 | }).then(wasm => { 125 | FFI = wasm.instance.exports; 126 | postMessage({type: "init", data: null}); 127 | }).catch(err => { 128 | postMessage({type: "init-error", data: err.toString()}); 129 | }); 130 | break; 131 | case "start": 132 | if (!vmsg_init(msg.data)) return postMessage({type: "error", data: "vmsg_init"}); 133 | break; 134 | case "data": 135 | if (!vmsg_encode(msg.data)) return postMessage({type: "error", data: "vmsg_encode"}); 136 | break; 137 | case "stop": 138 | const blob = vmsg_flush(); 139 | if (!blob) return postMessage({type: "error", data: "vmsg_flush"}); 140 | postMessage({type: "stop", data: blob}); 141 | break; 142 | } 143 | }; 144 | } 145 | 146 | export class Recorder { 147 | constructor(opts = {}, onStop) { 148 | // Can't use relative URL in blob worker, see: 149 | // https://stackoverflow.com/a/22582695 150 | this.wasmURL = new URL(opts.wasmURL || "/static/js/vmsg.wasm", location).href; 151 | this.shimURL = new URL(opts.shimURL || "/static/js/wasm-polyfill.js", location).href; 152 | this.onStop = onStop; 153 | this.pitch = opts.pitch || 0; 154 | this.audioCtx = null; 155 | this.gainNode = null; 156 | this.pitchFX = null; 157 | this.encNode = null; 158 | this.worker = null; 159 | this.workerURL = null; 160 | this.blob = null; 161 | this.blobURL = null; 162 | this.resolve = null; 163 | this.reject = null; 164 | Object.seal(this); 165 | } 166 | 167 | close() { 168 | if (this.encNode) this.encNode.disconnect(); 169 | if (this.encNode) this.encNode.onaudioprocess = null; 170 | if (this.audioCtx) this.audioCtx.close(); 171 | if (this.worker) this.worker.terminate(); 172 | if (this.workerURL) URL.revokeObjectURL(this.workerURL); 173 | if (this.blobURL) URL.revokeObjectURL(this.blobURL); 174 | } 175 | 176 | // Without pitch shift: 177 | // [sourceNode] -> [gainNode] -> [encNode] -> [audioCtx.destination] 178 | // | 179 | // -> [worker] 180 | // With pitch shift: 181 | // [sourceNode] -> [gainNode] -> [pitchFX] -> [encNode] -> [audioCtx.destination] 182 | // | 183 | // -> [worker] 184 | initAudio() { 185 | const getUserMedia = navigator.mediaDevices && navigator.mediaDevices.getUserMedia 186 | ? function(constraints) { 187 | return navigator.mediaDevices.getUserMedia(constraints); 188 | } 189 | : function(constraints) { 190 | const oldGetUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia; 191 | if (!oldGetUserMedia) { 192 | return Promise.reject(new Error("getUserMedia is not implemented in this browser")); 193 | } 194 | return new Promise(function(resolve, reject) { 195 | oldGetUserMedia.call(navigator, constraints, resolve, reject); 196 | }); 197 | }; 198 | 199 | return getUserMedia({audio: true}).then(stream => { 200 | const audioCtx = this.audioCtx = new (window.AudioContext 201 | || window.webkitAudioContext)(); 202 | 203 | const sourceNode = audioCtx.createMediaStreamSource(stream); 204 | const gainNode = this.gainNode = (audioCtx.createGain 205 | || audioCtx.createGainNode).call(audioCtx); 206 | gainNode.gain.value = 1; 207 | sourceNode.connect(gainNode); 208 | 209 | const pitchFX = this.pitchFX = new Jungle(audioCtx); 210 | pitchFX.setPitchOffset(this.pitch); 211 | 212 | const encNode = this.encNode = (audioCtx.createScriptProcessor 213 | || audioCtx.createJavaScriptNode).call(audioCtx, 0, 1, 1); 214 | pitchFX.output.connect(encNode); 215 | 216 | gainNode.connect(this.pitch === 0 ? encNode : pitchFX.input); 217 | }); 218 | } 219 | 220 | initWorker() { 221 | if (!this.audioCtx) throw new Error("missing audio initialization"); 222 | // https://stackoverflow.com/a/19201292 223 | const blob = new Blob( 224 | ["(", inlineWorker.toString(), ")()"], 225 | {type: "application/javascript"}); 226 | const workerURL = this.workerURL = URL.createObjectURL(blob); 227 | const worker = this.worker = new Worker(workerURL); 228 | const { wasmURL, shimURL } = this; 229 | worker.postMessage({type: "init", data: {wasmURL, shimURL}}); 230 | return new Promise((resolve, reject) => { 231 | worker.onmessage = (e) => { 232 | const msg = e.data; 233 | switch (msg.type) { 234 | case "init": 235 | resolve(); 236 | break; 237 | case "init-error": 238 | reject(new Error(msg.data)); 239 | break; 240 | // TODO(Kagami): Error handling. 241 | case "error": 242 | case "internal-error": 243 | console.error("Worker error:", msg.data); 244 | if (this.reject) this.reject(msg.data); 245 | break; 246 | case "stop": 247 | this.blob = msg.data; 248 | this.blobURL = URL.createObjectURL(msg.data); 249 | if (this.onStop) this.onStop(); 250 | if (this.resolve) this.resolve(this.blob); 251 | break; 252 | } 253 | } 254 | }); 255 | } 256 | 257 | startRecording() { 258 | if (!this.audioCtx) throw new Error("missing audio initialization"); 259 | if (!this.worker) throw new Error("missing worker initialization"); 260 | this.blob = null; 261 | if (this.blobURL) URL.revokeObjectURL(this.blobURL); 262 | this.blobURL = null; 263 | this.worker.postMessage({type: "start", data: this.audioCtx.sampleRate}); 264 | this.encNode.onaudioprocess = (e) => { 265 | const samples = e.inputBuffer.getChannelData(0); 266 | this.worker.postMessage({type: "data", data: samples}); 267 | }; 268 | this.encNode.connect(this.audioCtx.destination); 269 | } 270 | 271 | stopRecording() { 272 | const resultP = new Promise((resolve, reject) => { 273 | if (this.encNode) { 274 | this.encNode.disconnect(); 275 | this.encNode.onaudioprocess = null; 276 | } 277 | 278 | this.resolve = resolve; 279 | this.reject = reject; 280 | }); 281 | 282 | if (this.worker) { 283 | this.worker.postMessage({type: "stop", data: null}); 284 | } else { 285 | return Promise.resolve(this.blob); 286 | } 287 | 288 | return resultP; 289 | } 290 | } 291 | 292 | export class Form { 293 | constructor(opts = {}, resolve, reject) { 294 | this.recorder = new Recorder(opts, this.onStop.bind(this)); 295 | this.resolve = resolve; 296 | this.reject = reject; 297 | this.backdrop = null; 298 | this.popup = null; 299 | this.recordBtn = null; 300 | this.stopBtn = null; 301 | this.timer = null; 302 | this.audio = null; 303 | this.saveBtn = null; 304 | this.tid = 0; 305 | this.start = 0; 306 | Object.seal(this); 307 | 308 | this.recorder.initAudio() 309 | .then(() => this.drawInit()) 310 | .then(() => this.recorder.initWorker()) 311 | .then(() => this.drawAll()) 312 | .catch((err) => this.drawError(err)); 313 | } 314 | 315 | drawInit() { 316 | if (this.backdrop) return; 317 | const backdrop = this.backdrop = document.createElement("div"); 318 | backdrop.className = "vmsg-backdrop"; 319 | backdrop.addEventListener("click", () => this.close(null)); 320 | 321 | const popup = this.popup = document.createElement("div"); 322 | popup.className = "vmsg-popup"; 323 | popup.addEventListener("click", (e) => e.stopPropagation()); 324 | 325 | const progress = document.createElement("div"); 326 | progress.className = "vmsg-progress"; 327 | for (let i = 0; i < 3; i++) { 328 | const progressDot = document.createElement("div"); 329 | progressDot.className = "vmsg-progress-dot"; 330 | progress.appendChild(progressDot); 331 | } 332 | popup.appendChild(progress); 333 | 334 | backdrop.appendChild(popup); 335 | document.body.appendChild(backdrop); 336 | } 337 | 338 | drawTime(msecs) { 339 | const secs = Math.round(msecs / 1000); 340 | this.timer.textContent = pad2(secs / 60) + ":" + pad2(secs % 60); 341 | } 342 | 343 | drawAll() { 344 | this.drawInit(); 345 | this.clearAll(); 346 | 347 | const recordRow = document.createElement("div"); 348 | recordRow.className = "vmsg-record-row"; 349 | this.popup.appendChild(recordRow); 350 | 351 | const recordBtn = this.recordBtn = document.createElement("button"); 352 | recordBtn.className = "vmsg-button vmsg-record-button"; 353 | recordBtn.textContent = "●"; 354 | recordBtn.addEventListener("click", () => this.startRecording()); 355 | recordRow.appendChild(recordBtn); 356 | 357 | const stopBtn = this.stopBtn = document.createElement("button"); 358 | stopBtn.className = "vmsg-button vmsg-stop-button"; 359 | stopBtn.style.display = "none"; 360 | stopBtn.textContent = "■"; 361 | stopBtn.addEventListener("click", () => this.stopRecording()); 362 | recordRow.appendChild(stopBtn); 363 | 364 | const audio = this.audio = new Audio(); 365 | audio.autoplay = true; 366 | 367 | const timer = this.timer = document.createElement("span"); 368 | timer.className = "vmsg-timer"; 369 | timer.addEventListener("click", () => { 370 | if (audio.paused) { 371 | if (this.recorder.blobURL) { 372 | audio.src = this.recorder.blobURL; 373 | } 374 | } else { 375 | audio.pause(); 376 | } 377 | }); 378 | this.drawTime(0); 379 | recordRow.appendChild(timer); 380 | 381 | const saveBtn = this.saveBtn = document.createElement("button"); 382 | saveBtn.className = "vmsg-button vmsg-save-button"; 383 | saveBtn.textContent = "✓"; 384 | saveBtn.disabled = true; 385 | saveBtn.addEventListener("click", () => this.close(this.recorder.blob)); 386 | recordRow.appendChild(saveBtn); 387 | 388 | const gainWrapper = document.createElement("div"); 389 | gainWrapper.className = "vmsg-slider-wrapper vmsg-gain-slider-wrapper"; 390 | const gainSlider = document.createElement("input"); 391 | gainSlider.className = "vmsg-slider vmsg-gain-slider"; 392 | gainSlider.setAttribute("type", "range"); 393 | gainSlider.min = 0; 394 | gainSlider.max = 2; 395 | gainSlider.step = 0.2; 396 | gainSlider.value = 1; 397 | gainSlider.onchange = () => { 398 | const gain = +gainSlider.value; 399 | this.recorder.gainNode.gain.value = gain; 400 | }; 401 | gainWrapper.appendChild(gainSlider); 402 | this.popup.appendChild(gainWrapper); 403 | 404 | const pitchWrapper = document.createElement("div"); 405 | pitchWrapper.className = "vmsg-slider-wrapper vmsg-pitch-slider-wrapper"; 406 | const pitchSlider = document.createElement("input"); 407 | pitchSlider.className = "vmsg-slider vmsg-pitch-slider"; 408 | pitchSlider.setAttribute("type", "range"); 409 | pitchSlider.min = -1; 410 | pitchSlider.max = 1; 411 | pitchSlider.step = 0.2; 412 | pitchSlider.value = this.recorder.pitch; 413 | pitchSlider.onchange = () => { 414 | const pitch = +pitchSlider.value; 415 | this.recorder.pitchFX.setPitchOffset(pitch); 416 | this.recorder.gainNode.disconnect(); 417 | this.recorder.gainNode.connect( 418 | pitch === 0 ? this.recorder.encNode : this.recorder.pitchFX.input 419 | ); 420 | }; 421 | pitchWrapper.appendChild(pitchSlider); 422 | this.popup.appendChild(pitchWrapper); 423 | } 424 | 425 | drawError(err) { 426 | console.error(err); 427 | this.drawInit(); 428 | this.clearAll(); 429 | const error = document.createElement("div"); 430 | error.className = "vmsg-error"; 431 | error.textContent = err.toString(); 432 | this.popup.appendChild(error); 433 | } 434 | 435 | clearAll() { 436 | if (!this.popup) return; 437 | this.popup.innerHTML = ""; 438 | } 439 | 440 | close(blob) { 441 | if (this.audio) this.audio.pause(); 442 | if (this.tid) clearTimeout(this.tid); 443 | this.recorder.close(); 444 | this.backdrop.remove(); 445 | if (blob) { 446 | this.resolve(blob); 447 | } else { 448 | this.reject(new Error("No record made")); 449 | } 450 | } 451 | 452 | onStop() { 453 | this.recordBtn.style.display = ""; 454 | this.stopBtn.style.display = "none"; 455 | this.stopBtn.disabled = false; 456 | this.saveBtn.disabled = false; 457 | } 458 | 459 | startRecording() { 460 | this.audio.pause(); 461 | this.start = Date.now(); 462 | this.updateTime(); 463 | this.recordBtn.style.display = "none"; 464 | this.stopBtn.style.display = ""; 465 | this.saveBtn.disabled = true; 466 | this.recorder.startRecording(); 467 | } 468 | 469 | stopRecording() { 470 | clearTimeout(this.tid); 471 | this.tid = 0; 472 | this.stopBtn.disabled = true; 473 | this.recorder.stopRecording(); 474 | } 475 | 476 | updateTime() { 477 | // NOTE(Kagami): We can do this in `onaudioprocess` but that would 478 | // run too often and create unnecessary DOM updates. 479 | this.drawTime(Date.now() - this.start); 480 | this.tid = setTimeout(() => this.updateTime(), 300); 481 | } 482 | } 483 | 484 | let shown = false; 485 | 486 | /** 487 | * Record a new voice message. 488 | * 489 | * @param {Object=} opts - Options 490 | * @param {string=} opts.wasmURL - URL of the module 491 | * ("/static/js/vmsg.wasm" by default) 492 | * @param {string=} opts.shimURL - URL of the WebAssembly polyfill 493 | * ("/static/js/wasm-polyfill.js" by default) 494 | * @param {number=} opts.pitch - Initial pitch shift ([-1, 1], 0 by default) 495 | * @return {Promise.} A promise that contains recorded blob when fulfilled. 496 | */ 497 | export function record(opts) { 498 | return new Promise((resolve, reject) => { 499 | if (shown) throw new Error("Record form is already opened"); 500 | shown = true; 501 | new Form(opts, resolve, reject); 502 | // Use `.finally` once it's available in Safari and Edge. 503 | }).then(result => { 504 | shown = false; 505 | return result; 506 | }, err => { 507 | shown = false; 508 | throw err; 509 | }); 510 | } 511 | 512 | /** 513 | * All available public items. 514 | */ 515 | export default { Recorder, Form, record }; 516 | 517 | // Borrowed from and slightly modified: 518 | // https://github.com/cwilso/Audio-Input-Effects/blob/master/js/jungle.js 519 | 520 | // Copyright 2012, Google Inc. 521 | // All rights reserved. 522 | // 523 | // Redistribution and use in source and binary forms, with or without 524 | // modification, are permitted provided that the following conditions are 525 | // met: 526 | // 527 | // * Redistributions of source code must retain the above copyright 528 | // notice, this list of conditions and the following disclaimer. 529 | // * Redistributions in binary form must reproduce the above 530 | // copyright notice, this list of conditions and the following disclaimer 531 | // in the documentation and/or other materials provided with the 532 | // distribution. 533 | // * Neither the name of Google Inc. nor the names of its 534 | // contributors may be used to endorse or promote products derived from 535 | // this software without specific prior written permission. 536 | // 537 | // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 538 | // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 539 | // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 540 | // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 541 | // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 542 | // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 543 | // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 544 | // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 545 | // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 546 | // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 547 | // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 548 | 549 | const delayTime = 0.100; 550 | const fadeTime = 0.050; 551 | const bufferTime = 0.100; 552 | 553 | function createFadeBuffer(context, activeTime, fadeTime) { 554 | var length1 = activeTime * context.sampleRate; 555 | var length2 = (activeTime - 2*fadeTime) * context.sampleRate; 556 | var length = length1 + length2; 557 | var buffer = context.createBuffer(1, length, context.sampleRate); 558 | var p = buffer.getChannelData(0); 559 | 560 | var fadeLength = fadeTime * context.sampleRate; 561 | 562 | var fadeIndex1 = fadeLength; 563 | var fadeIndex2 = length1 - fadeLength; 564 | 565 | // 1st part of cycle 566 | for (var i = 0; i < length1; ++i) { 567 | var value; 568 | 569 | if (i < fadeIndex1) { 570 | value = Math.sqrt(i / fadeLength); 571 | } else if (i >= fadeIndex2) { 572 | value = Math.sqrt(1 - (i - fadeIndex2) / fadeLength); 573 | } else { 574 | value = 1; 575 | } 576 | 577 | p[i] = value; 578 | } 579 | 580 | // 2nd part 581 | for (var i = length1; i < length; ++i) { 582 | p[i] = 0; 583 | } 584 | 585 | return buffer; 586 | } 587 | 588 | function createDelayTimeBuffer(context, activeTime, fadeTime, shiftUp) { 589 | var length1 = activeTime * context.sampleRate; 590 | var length2 = (activeTime - 2*fadeTime) * context.sampleRate; 591 | var length = length1 + length2; 592 | var buffer = context.createBuffer(1, length, context.sampleRate); 593 | var p = buffer.getChannelData(0); 594 | 595 | // 1st part of cycle 596 | for (var i = 0; i < length1; ++i) { 597 | if (shiftUp) 598 | // This line does shift-up transpose 599 | p[i] = (length1-i)/length; 600 | else 601 | // This line does shift-down transpose 602 | p[i] = i / length1; 603 | } 604 | 605 | // 2nd part 606 | for (var i = length1; i < length; ++i) { 607 | p[i] = 0; 608 | } 609 | 610 | return buffer; 611 | } 612 | 613 | function Jungle(context) { 614 | this.context = context; 615 | // Create nodes for the input and output of this "module". 616 | var input = (context.createGain || context.createGainNode).call(context); 617 | var output = (context.createGain || context.createGainNode).call(context); 618 | this.input = input; 619 | this.output = output; 620 | 621 | // Delay modulation. 622 | var mod1 = context.createBufferSource(); 623 | var mod2 = context.createBufferSource(); 624 | var mod3 = context.createBufferSource(); 625 | var mod4 = context.createBufferSource(); 626 | this.shiftDownBuffer = createDelayTimeBuffer(context, bufferTime, fadeTime, false); 627 | this.shiftUpBuffer = createDelayTimeBuffer(context, bufferTime, fadeTime, true); 628 | mod1.buffer = this.shiftDownBuffer; 629 | mod2.buffer = this.shiftDownBuffer; 630 | mod3.buffer = this.shiftUpBuffer; 631 | mod4.buffer = this.shiftUpBuffer; 632 | mod1.loop = true; 633 | mod2.loop = true; 634 | mod3.loop = true; 635 | mod4.loop = true; 636 | 637 | // for switching between oct-up and oct-down 638 | var mod1Gain = (context.createGain || context.createGainNode).call(context); 639 | var mod2Gain = (context.createGain || context.createGainNode).call(context); 640 | var mod3Gain = (context.createGain || context.createGainNode).call(context); 641 | mod3Gain.gain.value = 0; 642 | var mod4Gain = (context.createGain || context.createGainNode).call(context); 643 | mod4Gain.gain.value = 0; 644 | 645 | mod1.connect(mod1Gain); 646 | mod2.connect(mod2Gain); 647 | mod3.connect(mod3Gain); 648 | mod4.connect(mod4Gain); 649 | 650 | // Delay amount for changing pitch. 651 | var modGain1 = (context.createGain || context.createGainNode).call(context); 652 | var modGain2 = (context.createGain || context.createGainNode).call(context); 653 | 654 | var delay1 = (context.createDelay || context.createDelayNode).call(context); 655 | var delay2 = (context.createDelay || context.createDelayNode).call(context); 656 | mod1Gain.connect(modGain1); 657 | mod2Gain.connect(modGain2); 658 | mod3Gain.connect(modGain1); 659 | mod4Gain.connect(modGain2); 660 | modGain1.connect(delay1.delayTime); 661 | modGain2.connect(delay2.delayTime); 662 | 663 | // Crossfading. 664 | var fade1 = context.createBufferSource(); 665 | var fade2 = context.createBufferSource(); 666 | var fadeBuffer = createFadeBuffer(context, bufferTime, fadeTime); 667 | fade1.buffer = fadeBuffer 668 | fade2.buffer = fadeBuffer; 669 | fade1.loop = true; 670 | fade2.loop = true; 671 | 672 | var mix1 = (context.createGain || context.createGainNode).call(context); 673 | var mix2 = (context.createGain || context.createGainNode).call(context); 674 | mix1.gain.value = 0; 675 | mix2.gain.value = 0; 676 | 677 | fade1.connect(mix1.gain); 678 | fade2.connect(mix2.gain); 679 | 680 | // Connect processing graph. 681 | input.connect(delay1); 682 | input.connect(delay2); 683 | delay1.connect(mix1); 684 | delay2.connect(mix2); 685 | mix1.connect(output); 686 | mix2.connect(output); 687 | 688 | // Start 689 | var t = context.currentTime + 0.050; 690 | var t2 = t + bufferTime - fadeTime; 691 | mod1.start(t); 692 | mod2.start(t2); 693 | mod3.start(t); 694 | mod4.start(t2); 695 | fade1.start(t); 696 | fade2.start(t2); 697 | 698 | this.mod1 = mod1; 699 | this.mod2 = mod2; 700 | this.mod1Gain = mod1Gain; 701 | this.mod2Gain = mod2Gain; 702 | this.mod3Gain = mod3Gain; 703 | this.mod4Gain = mod4Gain; 704 | this.modGain1 = modGain1; 705 | this.modGain2 = modGain2; 706 | this.fade1 = fade1; 707 | this.fade2 = fade2; 708 | this.mix1 = mix1; 709 | this.mix2 = mix2; 710 | this.delay1 = delay1; 711 | this.delay2 = delay2; 712 | 713 | this.setDelay(delayTime); 714 | } 715 | 716 | Jungle.prototype.setDelay = function(delayTime) { 717 | this.modGain1.gain.setTargetAtTime(0.5*delayTime, 0, 0.010); 718 | this.modGain2.gain.setTargetAtTime(0.5*delayTime, 0, 0.010); 719 | }; 720 | 721 | Jungle.prototype.setPitchOffset = function(mult) { 722 | if (mult>0) { // pitch up 723 | this.mod1Gain.gain.value = 0; 724 | this.mod2Gain.gain.value = 0; 725 | this.mod3Gain.gain.value = 1; 726 | this.mod4Gain.gain.value = 1; 727 | } else { // pitch down 728 | this.mod1Gain.gain.value = 1; 729 | this.mod2Gain.gain.value = 1; 730 | this.mod3Gain.gain.value = 0; 731 | this.mod4Gain.gain.value = 0; 732 | } 733 | this.setDelay(delayTime*Math.abs(mult)); 734 | }; 735 | -------------------------------------------------------------------------------- /vmsg.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/addpipe/simple-vmsg-demo/9cacde7f9035f06ab2321e9b9d3e3fe23728b426/vmsg.wasm --------------------------------------------------------------------------------