├── .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 | Record
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
--------------------------------------------------------------------------------