From 13779cc1b58ddbc9c2d95dbef4c2217381b6a6ed Mon Sep 17 00:00:00 2001 From: Rodrigo Nascimento Date: Mon, 17 Aug 2015 18:17:25 -0300 Subject: [PATCH] Initial implementation of audio recording --- client/lib/recorderjs/audioRecored.coffee | 32 +++++ client/lib/recorderjs/audiodisplay.js | 18 +++ client/lib/recorderjs/recorder.js | 92 ++++++++++++++ client/stylesheets/base.less | 14 ++- client/views/app/room.coffee | 14 +++ client/views/app/room.html | 6 + lib/fileUpload.coffee | 2 +- public/recorderWorker.js | 147 ++++++++++++++++++++++ 8 files changed, 323 insertions(+), 2 deletions(-) create mode 100644 client/lib/recorderjs/audioRecored.coffee create mode 100644 client/lib/recorderjs/audiodisplay.js create mode 100644 client/lib/recorderjs/recorder.js create mode 100644 public/recorderWorker.js diff --git a/client/lib/recorderjs/audioRecored.coffee b/client/lib/recorderjs/audioRecored.coffee new file mode 100644 index 00000000000..9a857d25ded --- /dev/null +++ b/client/lib/recorderjs/audioRecored.coffee @@ -0,0 +1,32 @@ +class @AudioRecorder + constructor: (cb) -> + window.AudioContext = window.AudioContext or window.webkitAudioContext + navigator.getUserMedia = navigator.getUserMedia or navigator.webkitGetUserMedia + window.URL = window.URL or window.webkitURL + + @audio_context = new AudioContext + + ok = (stream) => + @startUserMedia(stream) + cb?.call(@) + + navigator.getUserMedia {audio: true}, ok, (e) -> + console.log('No live audio input: ' + e) + + startUserMedia: (stream) -> + input = @audio_context.createMediaStreamSource(stream) + @recorder = new Recorder(input, {workerPath: '/recorderWorker.js'}) + + startRecording: (button) -> + @recorder.record() + + stopRecording: (cb) -> + @recorder.stop() + + if cb? + @getBlob cb + + @recorder.clear() + + getBlob: (cb) -> + @recorder.exportWAV cb \ No newline at end of file diff --git a/client/lib/recorderjs/audiodisplay.js b/client/lib/recorderjs/audiodisplay.js new file mode 100644 index 00000000000..a99622ca57c --- /dev/null +++ b/client/lib/recorderjs/audiodisplay.js @@ -0,0 +1,18 @@ +function drawBuffer( width, height, context, data ) { + var step = Math.ceil( data.length / width ); + var amp = height / 2; + context.fillStyle = "silver"; + context.clearRect(0,0,width,height); + for(var i=0; i < width; i++){ + var min = 1.0; + var max = -1.0; + for (j=0; j max) + max = datum; + } + context.fillRect(i,(1+min)*amp,1,Math.max(1,(max-min)*amp)); + } +} diff --git a/client/lib/recorderjs/recorder.js b/client/lib/recorderjs/recorder.js new file mode 100644 index 00000000000..e150e6a6088 --- /dev/null +++ b/client/lib/recorderjs/recorder.js @@ -0,0 +1,92 @@ +(function(window){ + + var WORKER_PATH = 'recorderWorker.js'; + + var Recorder = function(source, cfg){ + var config = cfg || {}; + var bufferLen = config.bufferLen || 4096; + var numChannels = config.numChannels || 2; + this.context = source.context; + this.node = (this.context.createScriptProcessor || + this.context.createJavaScriptNode).call(this.context, + bufferLen, numChannels, numChannels); + var worker = new Worker(config.workerPath || WORKER_PATH); + worker.postMessage({ + command: 'init', + config: { + sampleRate: this.context.sampleRate, + numChannels: numChannels + } + }); + var recording = false, + currCallback; + + this.node.onaudioprocess = function(e){ + if (!recording) return; + var buffer = []; + for (var channel = 0; channel < numChannels; channel++){ + buffer.push(e.inputBuffer.getChannelData(channel)); + } + worker.postMessage({ + command: 'record', + buffer: buffer + }); + } + + this.configure = function(cfg){ + for (var prop in cfg){ + if (cfg.hasOwnProperty(prop)){ + config[prop] = cfg[prop]; + } + } + } + + this.record = function(){ + recording = true; + } + + this.stop = function(){ + recording = false; + } + + this.clear = function(){ + worker.postMessage({ command: 'clear' }); + } + + this.getBuffer = function(cb) { + currCallback = cb || config.callback; + worker.postMessage({ command: 'getBuffer' }) + } + + this.exportWAV = function(cb, type){ + currCallback = cb || config.callback; + type = type || config.type || 'audio/wav'; + if (!currCallback) throw new Error('Callback not set'); + worker.postMessage({ + command: 'exportWAV', + type: type + }); + } + + worker.onmessage = function(e){ + var blob = e.data; + currCallback(blob); + } + + source.connect(this.node); + this.node.connect(this.context.destination); //this should not be necessary + }; + + Recorder.forceDownload = function(blob, filename){ + var url = (window.URL || window.webkitURL).createObjectURL(blob); + var link = window.document.createElement('a'); + link.href = url; + link.download = filename || 'output.wav'; + var click = document.createEvent("Event"); + click.initEvent("click", true, true); + link.dispatchEvent(click); + } + + window.Recorder = Recorder; + +})(window); \ No newline at end of file diff --git a/client/stylesheets/base.less b/client/stylesheets/base.less index 7530cf1e7b8..aaf4d958b0e 100644 --- a/client/stylesheets/base.less +++ b/client/stylesheets/base.less @@ -148,6 +148,10 @@ blockquote { display: none !important; } +.hidden { + display: none; +} + .small-title { font-size: 13px; text-transform: uppercase; @@ -1977,6 +1981,14 @@ a.github-fork { opacity: 0; } } + > .mic, .cancel-mic { + width: 44px; + font-size: 22px; + padding: 6px 0; + text-align: center; + cursor: pointer; + color: #888; + } } textarea { display: block; @@ -1992,7 +2004,7 @@ a.github-fork { .icon-paper-plane { position: absolute; top: 2px; - right: 10px; + right: 44px; cursor: pointer; padding: 8px; font-size: 16px; diff --git a/client/views/app/room.coffee b/client/views/app/room.coffee index 5c5b69e5ed3..cf718b38044 100644 --- a/client/views/app/room.coffee +++ b/client/views/app/room.coffee @@ -564,6 +564,20 @@ Template.room.events fileUpload filesToUpload + 'click .message-form .mic': (e, t) -> + t.recorder = new AudioRecorder -> + @startRecording() + + t.$('.cancel-mic').removeClass('hidden') + t.$('.mic').addClass('hidden') + + 'click .message-form .cancel-mic': (e, t) -> + t.recorder.stopRecording (blob) -> + fileUpload [{file: blob, name: 'Audio record'}] + console.log(arguments) + + t.$('.cancel-mic').addClass('hidden') + t.$('.mic').removeClass('hidden') 'click .deactivate': -> username = Session.get('showUserInfo') diff --git a/client/views/app/room.html b/client/views/app/room.html index 27b282cf062..d9c77a8c5e5 100644 --- a/client/views/app/room.html +++ b/client/views/app/room.html @@ -53,6 +53,12 @@ {{> messagePopupConfig getPupupConfig}} +
+ +
+
{{#with usersTyping}} diff --git a/lib/fileUpload.coffee b/lib/fileUpload.coffee index aaebb816bb2..e69606b1430 100644 --- a/lib/fileUpload.coffee +++ b/lib/fileUpload.coffee @@ -20,7 +20,7 @@ if FS? filter: maxSize: 2097152, allow: - contentTypes: ['image/*'] + contentTypes: ['image/*', 'audio/*'] onInvalid: (message) -> if Meteor.isClient toastr.error message diff --git a/public/recorderWorker.js b/public/recorderWorker.js new file mode 100644 index 00000000000..8182a45f894 --- /dev/null +++ b/public/recorderWorker.js @@ -0,0 +1,147 @@ +var recLength = 0, + recBuffers = [], + sampleRate, + numChannels; + +this.onmessage = function(e){ + switch(e.data.command){ + case 'init': + init(e.data.config); + break; + case 'record': + record(e.data.buffer); + break; + case 'exportWAV': + exportWAV(e.data.type); + break; + case 'getBuffer': + getBuffer(); + break; + case 'clear': + clear(); + break; + } +}; + +function init(config){ + sampleRate = config.sampleRate; + numChannels = config.numChannels; + initBuffers(); +} + +function record(inputBuffer){ + for (var channel = 0; channel < numChannels; channel++){ + recBuffers[channel].push(inputBuffer[channel]); + } + recLength += inputBuffer[0].length; +} + +function exportWAV(type){ + var buffers = []; + for (var channel = 0; channel < numChannels; channel++){ + buffers.push(mergeBuffers(recBuffers[channel], recLength)); + } + if (numChannels === 2){ + var interleaved = interleave(buffers[0], buffers[1]); + } else { + var interleaved = buffers[0]; + } + var dataview = encodeWAV(interleaved); + var audioBlob = new Blob([dataview], { type: type }); + + this.postMessage(audioBlob); +} + +function getBuffer(){ + var buffers = []; + for (var channel = 0; channel < numChannels; channel++){ + buffers.push(mergeBuffers(recBuffers[channel], recLength)); + } + this.postMessage(buffers); +} + +function clear(){ + recLength = 0; + recBuffers = []; + initBuffers(); +} + +function initBuffers(){ + for (var channel = 0; channel < numChannels; channel++){ + recBuffers[channel] = []; + } +} + +function mergeBuffers(recBuffers, recLength){ + var result = new Float32Array(recLength); + var offset = 0; + for (var i = 0; i < recBuffers.length; i++){ + result.set(recBuffers[i], offset); + offset += recBuffers[i].length; + } + return result; +} + +function interleave(inputL, inputR){ + var length = inputL.length + inputR.length; + var result = new Float32Array(length); + + var index = 0, + inputIndex = 0; + + while (index < length){ + result[index++] = inputL[inputIndex]; + result[index++] = inputR[inputIndex]; + inputIndex++; + } + return result; +} + +function floatTo16BitPCM(output, offset, input){ + for (var i = 0; i < input.length; i++, offset+=2){ + var s = Math.max(-1, Math.min(1, input[i])); + output.setInt16(offset, s < 0 ? s * 0x8000 : s * 0x7FFF, true); + } +} + +function writeString(view, offset, string){ + for (var i = 0; i < string.length; i++){ + view.setUint8(offset + i, string.charCodeAt(i)); + } +} + +function encodeWAV(samples){ + var buffer = new ArrayBuffer(44 + samples.length * 2); + var view = new DataView(buffer); + + /* RIFF identifier */ + writeString(view, 0, 'RIFF'); + /* RIFF chunk length */ + view.setUint32(4, 36 + samples.length * 2, true); + /* RIFF type */ + writeString(view, 8, 'WAVE'); + /* format chunk identifier */ + writeString(view, 12, 'fmt '); + /* format chunk length */ + view.setUint32(16, 16, true); + /* sample format (raw) */ + view.setUint16(20, 1, true); + /* channel count */ + view.setUint16(22, numChannels, true); + /* sample rate */ + view.setUint32(24, sampleRate, true); + /* byte rate (sample rate * block align) */ + view.setUint32(28, sampleRate * 4, true); + /* block align (channel count * bytes per sample) */ + view.setUint16(32, numChannels * 2, true); + /* bits per sample */ + view.setUint16(34, 16, true); + /* data chunk identifier */ + writeString(view, 36, 'data'); + /* data chunk length */ + view.setUint32(40, samples.length * 2, true); + + floatTo16BitPCM(view, 44, samples); + + return view; +} \ No newline at end of file