diff --git a/src/app/common/services/recorder-service.coffee b/src/app/common/services/recorder-service.coffee deleted file mode 100644 index b3e6408e4e..0000000000 --- a/src/app/common/services/recorder-service.coffee +++ /dev/null @@ -1,259 +0,0 @@ -# Parts adapted from https://github.com/kaliatech/web-audio-recording-tests - -angular.module("doubtfire.common.services.recorder-service", []) -# -# Services for working with cross-platform, media Recording APIs -# -.factory("recorderService", ($rootScope, $timeout, $sce) -> - return class RecorderService - constructor: () -> - window.AudioContext = window.AudioContext || window.webkitAudioContext - - @em = document.createDocumentFragment() - @state = 'inactive' - @audioCtx = {} - @chunks = [] - @chunkType = '' - - @usingMediaRecorder = window.MediaRecorder || false - - # MediaRecording on Safari is broken for us in some specific way which I'm not sure how to fix yet. - if /^((?!chrome|android).)*safari/i.test(navigator.userAgent) then @usingMediaRecorder = false - - @encoderMimeType - - @config = { - broadcastAudioProcessEvents: false, - createAnalyserNode: true, - createDynamicsCompressorNode: false, - forceScriptProcessor: false, - manualEncoderId: 'wav', - micGain: 1.0, - processorBufferSize: 2048, - stopTracksAndCloseCtxWhenFinished: true, - userMediaConstraints: { - audio: true - } - audioBitsPerSecond: 128000 - } - return - - # Called once when the recording is initated - startRecording: () -> - if (@state != 'inactive') - return - - # This is the case on ios/chrome, when clicking links from within ios/slack (sometimes), etc. - if (!navigator || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) - console.error('Missing support for navigator.mediaDevices.getUserMedia') # temp: helps when testing for strange issues on ios/safari - return - - @audioCtx = new AudioContext() - @micGainNode = @audioCtx.createGain() - @outputGainNode = @audioCtx.createGain() - - if (@config.createDynamicsCompressorNode) - @dynamicsCompressorNode = audioCtx.createDynamicsCompressor() - - - if (@config.createAnalyserNode) - @analyserNode = @audioCtx.createAnalyser() - - - # If not using MediaRecorder(i.e. safari and edge), then a script processor is required. It's optional - # on browsers using MediaRecorder and is only useful if wanting to do custom analysis or manipulation of - # recorded audio data. - if (@config.forceScriptProcessor || @config.broadcastAudioProcessEvents || !@usingMediaRecorder) - @processorNode = @audioCtx.createScriptProcessor(@config.processorBufferSize, 1, 1) # TODO: Get the number of channels from mic - - # Create stream destination on chrome/firefox because, AFAICT, we have no other way of feeding audio graph output - # in to MediaRecorderSafari/Edge don't have this method as of 2018-04. - if (@audioCtx.createMediaStreamDestination) - @destinationNode = @audioCtx.createMediaStreamDestination() - else - @destinationNode = @audioCtx.destination - - # Create web worker for doing the encoding - if (!@usingMediaRecorder) - @encoderWorker = new Worker('/assets/wav-worker.js') - @encoderMimeType = 'audio/wav' - - that = this - @encoderWorker.addEventListener('message', (e) -> - event = new Event('dataavailable') - if (that.config.manualEncoderId == 'ogg') - event.data = e.data - else - event.data = new Blob(e.data, { type: that.encoderMimeType }) - that._onDataAvailable(event) - ) - - # This will prompt user for permission if needed - that = this - return navigator.mediaDevices.getUserMedia(@config.userMediaConstraints) - .then((stream) -> - that._startRecordingWithStream(stream) - ) - .catch((error) -> - return - ) - return - - setMicGain: (newGain) -> - @config.micGain = newGain - if (@audioCtx && @micGainNode) - @micGainNode.gain.setValueAtTime(newGain, @audioCtx.currentTime) - return - - _startRecordingWithStream: (stream) -> - @micAudioStream = stream - @inputStreamNode = @audioCtx.createMediaStreamSource(@micAudioStream) - @audioCtx = @inputStreamNode.context - - # Kind-of a hack to allow hooking in to audioGraph mediaRecorder.inputStreamNode - if (@onGraphSetupWithInputStream) - @onGraphSetupWithInputStream(@inputStreamNode) - - @inputStreamNode.connect(@micGainNode) - @micGainNode.gain.setValueAtTime(@config.micGain, @audioCtx.currentTime) - - nextNode = @micGainNode - if (@dynamicsCompressorNode) - @micGainNode.connect(@dynamicsCompressorNode) - nextNode = @dynamicsCompressorNode - - @state = 'recording' - - if (@processorNode) - nextNode.connect(@processorNode) - @processorNode.connect(@outputGainNode) - that = this - @processorNode.onaudioprocess = (e) -> that._onAudioProcess(e) - else - nextNode.connect(@outputGainNode) - - if (@analyserNode) - nextNode.connect(@analyserNode) - - @outputGainNode.connect(@destinationNode) - - if (@usingMediaRecorder) - @mediaRecorder = new MediaRecorder(@destinationNode.stream, { audioBitsPerSecond: @config.audioBitsPerSecond }) - that = this - @mediaRecorder.addEventListener('dataavailable', (evt) -> that._onDataAvailable(evt)) - @mediaRecorder.addEventListener('error', (evt) -> @_onError(evt)) - - @mediaRecorder.start() - else - @outputGainNode.gain.setValueAtTime(0, @audioCtx.currentTime) - return - - _onAudioProcess: (e) -> - if (@config.broadcastAudioProcessEvents) - @em.dispatchEvent(new CustomEvent('onaudioprocess', { - detail: { - inputBuffer: e.inputBuffer, - outputBuffer: e.outputBuffer - } - })) - if (!@usingMediaRecorder) - if (@state == 'recording') - if (@config.broadcastAudioProcessEvents) - @encoderWorker.postMessage(['encode', e.outputBuffer.getChannelData(0)]) - else - @encoderWorker.postMessage(['encode', e.inputBuffer.getChannelData(0)]) - return - - processChunks: () -> - if (@state == 'inactive') - return - this._dumpChunks() - return - - _dumpChunks: () -> - if(@usingMediaRecorder) - @mediaRecorder.requestData() - - if (!@usingMediaRecorder) - @encoderWorker.postMessage(['dump', @audioCtx.sampleRate]) - clearInterval(@slicing) - - # Called once when the recording has been stopped - stopRecording: () -> - if (@state == 'inactive') - return - if (@usingMediaRecorder) - @state = 'inactive' - @mediaRecorder.stop() - else - @state = 'inactive' - @encoderWorker.postMessage(['dump', @audioCtx.sampleRate]) - clearInterval(@slicing) - return - - # Called each time a chunk of recording becomes available - _onDataAvailable: (evt) -> - @chunks.push(evt.data) - @chunkType = evt.data.type - - blob = new Blob(@chunks, { 'type': @chunkType }) - blobUrl = URL.createObjectURL(blob) - recording = { - ts: new Date().getTime(), - blobUrl: blobUrl, - mimeType: blob.type, - size: blob.size - blob: blob - } - - @em.dispatchEvent(new CustomEvent('recording', { detail: { recording: recording } })) - - @chunks = [] - - if (@state != 'inactive') - return - - this._cleanup() - return - - _cleanup: () -> - @chunkType = null - if (@destinationNode) - @destinationNode.disconnect() - @destinationNode = null - if (@outputGainNode) - @outputGainNode.disconnect() - @outputGainNode = null - if (@analyserNode) - @analyserNode.disconnect() - @analyserNode = null - if (@processorNode) - @processorNode.disconnect() - @processorNode = null - if (@encoderWorker) - @encoderWorker.postMessage(['close']) - @encoderWorker = null - if (@dynamicsCompressorNode) - @dynamicsCompressorNode.disconnect() - @dynamicsCompressorNode = null - if (@micGainNode) - @micGainNode.disconnect() - @micGainNode = null - if (@inputStreamNode) - @inputStreamNode.disconnect() - @inputStreamNode = null - - if (@config.stopTracksAndCloseCtxWhenFinished) - # This removes the red bar in iOS/Safari - @micAudioStream.getTracks().forEach((track) -> track.stop()) - @micAudioStream = null - - @audioCtx.close() - @audioCtx = null - - return - - _onError: (evt) -> - @em.dispatchEvent(new Event('error')) - return -) diff --git a/src/app/common/services/recorder-service.ts b/src/app/common/services/recorder-service.ts new file mode 100644 index 0000000000..be1e379fec --- /dev/null +++ b/src/app/common/services/recorder-service.ts @@ -0,0 +1,348 @@ +import { Injectable } from '@angular/core'; + +// Interface for the recording configuration +interface RecorderConfig { + broadcastAudioProcessEvents: boolean; + createAnalyserNode: boolean; + createDynamicsCompressorNode: boolean; + forceScriptProcessor: boolean; + manualEncoderId: string; + micGain: number; + processorBufferSize: number; + stopTracksAndCloseCtxWhenFinished: boolean; + userMediaConstraints: MediaStreamConstraints; + audioBitsPerSecond: number; +} + +// Interface for the recording result +interface RecordingResult { + ts: number; + blobUrl: string; + mimeType: string; + size: number; + blob: Blob; +} + +@Injectable({ + providedIn: 'root' +}) +export class RecorderService { + + constructor() { + return RecorderServiceClass as any; + } +} + +class RecorderServiceClass { + public em: DocumentFragment = document.createDocumentFragment(); + + private state: string = 'inactive'; + private audioCtx: AudioContext | null = null; + private chunks: Blob[] = []; + private chunkType: string = ''; + private usingMediaRecorder: boolean = false; + private encoderMimeType: string = ''; + + // Audio nodes + private micGainNode: GainNode | null = null; + private outputGainNode: GainNode | null = null; + private analyserNode: AnalyserNode | null = null; + private processorNode: ScriptProcessorNode | null = null; + private dynamicsCompressorNode: DynamicsCompressorNode | null = null; + private destinationNode: MediaStreamAudioDestinationNode | AudioDestinationNode | null = null; + private inputStreamNode: MediaStreamAudioSourceNode | null = null; + + // Media related + private micAudioStream: MediaStream | null = null; + private mediaRecorder: MediaRecorder | null = null; + private encoderWorker: Worker | null = null; + + // Configuration + private config: RecorderConfig; + + // Optional hook function + public onGraphSetupWithInputStream?: (inputNode: MediaStreamAudioSourceNode) => void; + + constructor() { + // Set up AudioContext with fallback for older browsers + (window as any).AudioContext = (window as any).AudioContext || (window as any).webkitAudioContext; + + // MediaRecorder support detection + this.usingMediaRecorder = !!(window as any).MediaRecorder; + + if (/^((?!chrome|android).)*safari/i.test(navigator.userAgent)) { + this.usingMediaRecorder = false; + } + + this.config = { + broadcastAudioProcessEvents: false, + createAnalyserNode: true, + createDynamicsCompressorNode: false, + forceScriptProcessor: false, + manualEncoderId: 'wav', + micGain: 1.0, + processorBufferSize: 2048, + stopTracksAndCloseCtxWhenFinished: true, + userMediaConstraints: { + audio: true + }, + audioBitsPerSecond: 128000 + }; + } + + public startRecording(): Promise | void { + if (this.state !== 'inactive') { + return; + } + + if (!navigator || !navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + return; + } + + this.audioCtx = new AudioContext(); + this.micGainNode = this.audioCtx.createGain(); + this.outputGainNode = this.audioCtx.createGain(); + + if (this.config.createDynamicsCompressorNode) { + this.dynamicsCompressorNode = this.audioCtx.createDynamicsCompressor(); + } + + if (this.config.createAnalyserNode) { + this.analyserNode = this.audioCtx.createAnalyser(); + } + + + if (this.config.forceScriptProcessor || this.config.broadcastAudioProcessEvents || !this.usingMediaRecorder) { + this.processorNode = this.audioCtx.createScriptProcessor(this.config.processorBufferSize, 1, 1); + } + + + if ((this.audioCtx as any).createMediaStreamDestination) { + this.destinationNode = (this.audioCtx as any).createMediaStreamDestination(); + } else { + this.destinationNode = this.audioCtx.destination; + } + + if (!this.usingMediaRecorder) { + this.encoderWorker = new Worker('/assets/wav-worker.js'); + this.encoderMimeType = 'audio/wav'; + + this.encoderWorker.addEventListener('message', (e) => { + const event = { + data: this.config.manualEncoderId === 'ogg' + ? e.data + : new Blob(e.data, { type: this.encoderMimeType }) + }; + this.onDataAvailable(event); + }); + } + + return navigator.mediaDevices.getUserMedia(this.config.userMediaConstraints) + .then((stream) => { + this.startRecordingWithStream(stream); + }) + .catch(() => { + this.em.dispatchEvent(new Event('error')); + }); + } + + public setMicGain(newGain: number): void { + this.config.micGain = newGain; + if (this.audioCtx && this.micGainNode) { + this.micGainNode.gain.setValueAtTime(newGain, this.audioCtx.currentTime); + } + } + + private startRecordingWithStream(stream: MediaStream): void { + this.micAudioStream = stream; + this.inputStreamNode = this.audioCtx!.createMediaStreamSource(this.micAudioStream); + this.audioCtx = this.inputStreamNode.context as AudioContext; + + if (this.onGraphSetupWithInputStream) { + this.onGraphSetupWithInputStream(this.inputStreamNode); + } + + this.inputStreamNode.connect(this.micGainNode!); + this.micGainNode!.gain.setValueAtTime(this.config.micGain, this.audioCtx.currentTime); + + let nextNode: AudioNode = this.micGainNode!; + if (this.dynamicsCompressorNode) { + this.micGainNode!.connect(this.dynamicsCompressorNode); + nextNode = this.dynamicsCompressorNode; + } + + this.state = 'recording'; + + if (this.processorNode) { + nextNode.connect(this.processorNode); + this.processorNode.connect(this.outputGainNode!); + this.processorNode.onaudioprocess = (e) => this.onAudioProcess(e); + } else { + nextNode.connect(this.outputGainNode!); + } + + if (this.analyserNode) { + nextNode.connect(this.analyserNode); + } + + this.outputGainNode!.connect(this.destinationNode!); + + if (this.usingMediaRecorder) { + const destinationStream = (this.destinationNode as MediaStreamAudioDestinationNode).stream; + this.mediaRecorder = new MediaRecorder(destinationStream, { audioBitsPerSecond: this.config.audioBitsPerSecond }); + + this.mediaRecorder.addEventListener('dataavailable', (evt) => { + this.onDataAvailable(evt); + }); + this.mediaRecorder.addEventListener('error', (evt) => { + this.onError(evt); + }); + + this.mediaRecorder.start(); + } else { + this.outputGainNode!.gain.setValueAtTime(0, this.audioCtx.currentTime); + } + } + + private onAudioProcess(e: AudioProcessingEvent): void { + if (this.config.broadcastAudioProcessEvents) { + this.em.dispatchEvent(new CustomEvent('onaudioprocess', { + detail: { + inputBuffer: e.inputBuffer, + outputBuffer: e.outputBuffer + } + })); + } + + if (!this.usingMediaRecorder) { + if (this.state === 'recording') { + if (this.config.broadcastAudioProcessEvents) { + this.encoderWorker!.postMessage(['encode', e.outputBuffer.getChannelData(0)]); + } else { + this.encoderWorker!.postMessage(['encode', e.inputBuffer.getChannelData(0)]); + } + } + } + } + + public processChunks(): void { + if (this.state === 'inactive') { + return; + } + this.dumpChunks(); + } + + private dumpChunks(): void { + if (this.usingMediaRecorder) { + this.mediaRecorder!.requestData(); + } + + if (!this.usingMediaRecorder) { + this.encoderWorker!.postMessage(['dump', this.audioCtx!.sampleRate]); + } + } + + // Called once when the recording has been stopped + public stopRecording(): void { + if (this.state === 'inactive') { + return; + } + + if (this.usingMediaRecorder) { + this.state = 'inactive'; + this.mediaRecorder!.stop(); + } else { + this.state = 'inactive'; + this.encoderWorker!.postMessage(['dump', this.audioCtx!.sampleRate]); + } + } + + // Called each time a chunk of recording becomes available + private onDataAvailable(evt: { data: Blob }): void { + this.chunks.push(evt.data); + this.chunkType = evt.data.type; + + const blob = new Blob(this.chunks, { type: this.chunkType }); + const blobUrl = URL.createObjectURL(blob); + + const recording: RecordingResult = { + ts: new Date().getTime(), + blobUrl: blobUrl, + mimeType: blob.type, + size: blob.size, + blob: blob + }; + + this.em.dispatchEvent(new CustomEvent('recording', { detail: { recording: recording } })); + + this.chunks = []; + + if (this.state !== 'inactive') { + return; + } + + this.cleanup(); + } + + private cleanup(): void { + this.chunkType = ''; + + if (this.destinationNode) { + this.destinationNode.disconnect(); + this.destinationNode = null; + } + + if (this.outputGainNode) { + this.outputGainNode.disconnect(); + this.outputGainNode = null; + } + + if (this.analyserNode) { + this.analyserNode.disconnect(); + this.analyserNode = null; + } + + if (this.processorNode) { + this.processorNode.disconnect(); + this.processorNode = null; + } + + if (this.encoderWorker) { + this.encoderWorker.postMessage(['close']); + this.encoderWorker = null; + } + + if (this.dynamicsCompressorNode) { + this.dynamicsCompressorNode.disconnect(); + this.dynamicsCompressorNode = null; + } + + if (this.micGainNode) { + this.micGainNode.disconnect(); + this.micGainNode = null; + } + + if (this.inputStreamNode) { + this.inputStreamNode.disconnect(); + this.inputStreamNode = null; + } + + if (this.config.stopTracksAndCloseCtxWhenFinished) { + if (this.micAudioStream) { + this.micAudioStream.getTracks().forEach((track) => { + track.stop(); + }); + this.micAudioStream = null; + } + + if (this.audioCtx) { + this.audioCtx.close(); + this.audioCtx = null; + } + } + } + + private onError(evt: any): void { + this.em.dispatchEvent(new Event('error')); + } +} diff --git a/src/app/common/services/services.coffee b/src/app/common/services/services.coffee index e88e7a7bdc..b4a5a6f1f6 100644 --- a/src/app/common/services/services.coffee +++ b/src/app/common/services/services.coffee @@ -3,5 +3,4 @@ angular.module("doubtfire.common.services", [ 'doubtfire.common.services.analytics' 'doubtfire.common.services.dates' 'doubtfire.common.services.listener' - 'doubtfire.common.services.recorder-service' ]) diff --git a/src/app/doubtfire-angular.module.ts b/src/app/doubtfire-angular.module.ts index c714ff0e5a..0920c3209a 100644 --- a/src/app/doubtfire-angular.module.ts +++ b/src/app/doubtfire-angular.module.ts @@ -253,6 +253,7 @@ import {ScormExtensionModalComponent} from './common/modals/scorm-extension-moda import { GradeIconComponent } from './common/grade-icon/grade-icon.component'; import { GradeTaskModalComponent } from './tasks/modals/grade-task-modal/grade-task-modal.component'; import { PrivacyPolicy } from './config/privacy-policy/privacy-policy'; +import { RecorderService } from './common/services/recorder-service'; // See https://stackoverflow.com/questions/55721254/how-to-change-mat-datepicker-date-format-to-dd-mm-yyyy-in-simplest-way/58189036#58189036 const MY_DATE_FORMAT = { @@ -465,6 +466,7 @@ import { UnitStudentEnrolmentModalComponent } from './units/modals/unit-student- ScormAdapterService, TestAttemptService, PrivacyPolicy, + RecorderService, provideLottieOptions({ player: () => player, }), diff --git a/src/app/doubtfire-angularjs.module.ts b/src/app/doubtfire-angularjs.module.ts index d9dc6955d5..a53bef40af 100644 --- a/src/app/doubtfire-angularjs.module.ts +++ b/src/app/doubtfire-angularjs.module.ts @@ -121,7 +121,6 @@ import 'build/src/app/common/common.js'; import 'build/src/app/common/services/listener-service.js'; import 'build/src/app/common/services/outcome-service.js'; import 'build/src/app/common/services/services.js'; -import 'build/src/app/common/services/recorder-service.js'; import 'build/src/app/common/services/media-service.js'; import 'build/src/app/common/services/analytics-service.js'; import 'build/src/app/common/services/date-service.js'; @@ -224,6 +223,7 @@ import {GradeService} from './common/services/grade.service'; import {TaskScormCardComponent} from './projects/states/dashboard/directives/task-dashboard/directives/task-scorm-card/task-scorm-card.component'; import { UnitStudentEnrolmentModalService } from './units/modals/unit-student-enrolment-modal/unit-student-enrolment-modal.service'; import { PrivacyPolicy } from './config/privacy-policy/privacy-policy'; +import { RecorderService } from './common/services/recorder-service'; export const DoubtfireAngularJSModule = angular.module('doubtfire', [ 'doubtfire.config', @@ -304,6 +304,7 @@ DoubtfireAngularJSModule.factory('CreateNewUnitModal', downgradeInjectable(Creat DoubtfireAngularJSModule.factory('GradeTaskModal', downgradeInjectable(GradeTaskModalService)); DoubtfireAngularJSModule.factory('UnitStudentEnrolmentModal', downgradeInjectable(UnitStudentEnrolmentModalService)); DoubtfireAngularJSModule.factory('PrivacyPolicy', downgradeInjectable(PrivacyPolicy)); +DoubtfireAngularJSModule.factory('recorderService', downgradeInjectable(RecorderService)); // directive -> component DoubtfireAngularJSModule.directive(