@@ -23,6 +23,8 @@ import {clamp} from "../utils/numbers";
2323import EventEmitter from "events" ;
2424import { IDestroyable } from "../utils/IDestroyable" ;
2525import { Singleflight } from "../utils/Singleflight" ;
26+ import { PayloadEvent , WORKLET_NAME } from "./consts" ;
27+ import { arrayFastClone } from "../utils/arrays" ;
2628
2729const CHANNELS = 1 ; // stereo isn't important
2830const SAMPLE_RATE = 48000 ; // 48khz is what WebRTC uses. 12khz is where we lose quality.
@@ -49,16 +51,34 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
4951 private recorderSource : MediaStreamAudioSourceNode ;
5052 private recorderStream : MediaStream ;
5153 private recorderFFT : AnalyserNode ;
52- private recorderProcessor : ScriptProcessorNode ;
54+ private recorderWorklet : AudioWorkletNode ;
5355 private buffer = new Uint8Array ( 0 ) ;
5456 private mxc : string ;
5557 private recording = false ;
5658 private observable : SimpleObservable < IRecordingUpdate > ;
59+ private amplitudes : number [ ] = [ ] ; // at each second mark, generated
5760
5861 public constructor ( private client : MatrixClient ) {
5962 super ( ) ;
6063 }
6164
65+ public get finalWaveform ( ) : number [ ] {
66+ return arrayFastClone ( this . amplitudes ) ;
67+ }
68+
69+ public get contentType ( ) : string {
70+ return "audio/ogg" ;
71+ }
72+
73+ public get contentLength ( ) : number {
74+ return this . buffer . length ;
75+ }
76+
77+ public get durationSeconds ( ) : number {
78+ if ( ! this . recorder ) throw new Error ( "Duration not available without a recording" ) ;
79+ return this . recorderContext . currentTime ;
80+ }
81+
6282 private async makeRecorder ( ) {
6383 this . recorderStream = await navigator . mediaDevices . getUserMedia ( {
6484 audio : {
@@ -80,18 +100,34 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
80100 // it makes the time domain less than helpful.
81101 this . recorderFFT . fftSize = 64 ;
82102
83- // We use an audio processor to get accurate timing information.
84- // The size of the audio buffer largely decides how quickly we push timing/waveform data
85- // out of this class. Smaller buffers mean we update more frequently as we can't hold as
86- // many bytes. Larger buffers mean slower updates. For scale, 1024 gives us about 30Hz of
87- // updates and 2048 gives us about 20Hz. We use 1024 to get as close to perceived realtime
88- // as possible. Must be a power of 2.
89- this . recorderProcessor = this . recorderContext . createScriptProcessor ( 1024 , CHANNELS , CHANNELS ) ;
103+ // Set up our worklet. We use this for timing information and waveform analysis: the
104+ // web audio API prefers this be done async to avoid holding the main thread with math.
105+ const mxRecorderWorkletPath = document . body . dataset . vectorRecorderWorkletScript ;
106+ if ( ! mxRecorderWorkletPath ) {
107+ throw new Error ( "Unable to create recorder: no worklet script registered" ) ;
108+ }
109+ await this . recorderContext . audioWorklet . addModule ( mxRecorderWorkletPath ) ;
110+ this . recorderWorklet = new AudioWorkletNode ( this . recorderContext , WORKLET_NAME ) ;
90111
91112 // Connect our inputs and outputs
92113 this . recorderSource . connect ( this . recorderFFT ) ;
93- this . recorderSource . connect ( this . recorderProcessor ) ;
94- this . recorderProcessor . connect ( this . recorderContext . destination ) ;
114+ this . recorderSource . connect ( this . recorderWorklet ) ;
115+ this . recorderWorklet . connect ( this . recorderContext . destination ) ;
116+
117+ // Dev note: we can't use `addEventListener` for some reason. It just doesn't work.
118+ this . recorderWorklet . port . onmessage = ( ev ) => {
119+ switch ( ev . data [ 'ev' ] ) {
120+ case PayloadEvent . Timekeep :
121+ this . processAudioUpdate ( ev . data [ 'timeSeconds' ] ) ;
122+ break ;
123+ case PayloadEvent . AmplitudeMark :
124+ // Sanity check to make sure we're adding about one sample per second
125+ if ( ev . data [ 'forSecond' ] === this . amplitudes . length ) {
126+ this . amplitudes . push ( ev . data [ 'amplitude' ] ) ;
127+ }
128+ break ;
129+ }
130+ } ;
95131
96132 this . recorder = new Recorder ( {
97133 encoderPath, // magic from webpack
@@ -138,7 +174,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
138174 return this . mxc ;
139175 }
140176
141- private processAudioUpdate = ( ev : AudioProcessingEvent ) => {
177+ private processAudioUpdate = ( timeSeconds : number ) => {
142178 if ( ! this . recording ) return ;
143179
144180 // The time domain is the input to the FFT, which means we use an array of the same
@@ -162,12 +198,12 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
162198
163199 this . observable . update ( {
164200 waveform : translatedData ,
165- timeSeconds : ev . playbackTime ,
201+ timeSeconds : timeSeconds ,
166202 } ) ;
167203
168204 // Now that we've updated the data/waveform, let's do a time check. We don't want to
169205 // go horribly over the limit. We also emit a warning state if needed.
170- const secondsLeft = TARGET_MAX_LENGTH - ev . playbackTime ;
206+ const secondsLeft = TARGET_MAX_LENGTH - timeSeconds ;
171207 if ( secondsLeft <= 0 ) {
172208 // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
173209 this . stop ( ) ;
@@ -191,7 +227,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
191227 }
192228 this . observable = new SimpleObservable < IRecordingUpdate > ( ) ;
193229 await this . makeRecorder ( ) ;
194- this . recorderProcessor . addEventListener ( "audioprocess" , this . processAudioUpdate ) ;
195230 await this . recorder . start ( ) ;
196231 this . recording = true ;
197232 this . emit ( RecordingState . Started ) ;
@@ -205,6 +240,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
205240
206241 // Disconnect the source early to start shutting down resources
207242 this . recorderSource . disconnect ( ) ;
243+ this . recorderWorklet . disconnect ( ) ;
208244 await this . recorder . stop ( ) ;
209245
210246 // close the context after the recorder so the recorder doesn't try to
@@ -216,7 +252,6 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
216252
217253 // Finally do our post-processing and clean up
218254 this . recording = false ;
219- this . recorderProcessor . removeEventListener ( "audioprocess" , this . processAudioUpdate ) ;
220255 await this . recorder . close ( ) ;
221256 this . emit ( RecordingState . Ended ) ;
222257
@@ -240,7 +275,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
240275
241276 this . emit ( RecordingState . Uploading ) ;
242277 this . mxc = await this . client . uploadContent ( new Blob ( [ this . buffer ] , {
243- type : "audio/ogg" ,
278+ type : this . contentType ,
244279 } ) , {
245280 onlyContentUri : false , // to stop the warnings in the console
246281 } ) . then ( r => r [ 'content_uri' ] ) ;
0 commit comments