extendable-media-recorder
Version:
An extendable drop-in replacement for the native MediaRecorder.
197 lines (153 loc) • 8.63 kB
text/typescript
import { encode, instantiate } from 'media-encoder-host';
import { MultiBufferDataView } from 'multi-buffer-data-view';
import { on } from 'subscribable-things';
import { TPromisedDataViewElementTypeEncoderInstanceIdAndPort, TRecordingState, TWebmPcmMediaRecorderFactoryFactory } from '../types';
export const createWebmPcmMediaRecorderFactory: TWebmPcmMediaRecorderFactoryFactory = (
createBlobEvent,
decodeWebMChunk,
readVariableSizeInteger
) => {
return (eventTarget, nativeMediaRecorderConstructor, mediaStream, mimeType) => {
const bufferedArrayBuffers: ArrayBuffer[] = [];
const nativeMediaRecorder = new nativeMediaRecorderConstructor(mediaStream, { mimeType: 'audio/webm;codecs=pcm' });
let promisedPartialRecording: null | Promise<void> = null;
let stopRecording = () => {}; // tslint:disable-line:no-empty
const dispatchDataAvailableEvent = (arrayBuffers: ArrayBuffer[]): void => {
eventTarget.dispatchEvent(createBlobEvent('dataavailable', { data: new Blob(arrayBuffers, { type: mimeType }) }));
};
const requestNextPartialRecording = async (encoderInstanceId: number, timeslice: number): Promise<void> => {
const arrayBuffers = await encode(encoderInstanceId, timeslice);
if (nativeMediaRecorder.state === 'inactive') {
bufferedArrayBuffers.push(...arrayBuffers);
} else {
dispatchDataAvailableEvent(arrayBuffers);
promisedPartialRecording = requestNextPartialRecording(encoderInstanceId, timeslice);
}
};
const stop = (): void => {
if (nativeMediaRecorder.state === 'inactive') {
return;
}
if (promisedPartialRecording !== null) {
promisedPartialRecording.catch(() => {
/* @todo Only catch the errors caused by a duplicate call to encode. */
});
promisedPartialRecording = null;
}
stopRecording();
stopRecording = () => {}; // tslint:disable-line:no-empty
nativeMediaRecorder.stop();
};
nativeMediaRecorder.addEventListener('error', (event) => {
stop();
eventTarget.dispatchEvent(
new ErrorEvent('error', {
error: (<ErrorEvent>event).error
})
);
});
nativeMediaRecorder.addEventListener('pause', () => eventTarget.dispatchEvent(new Event('pause')));
nativeMediaRecorder.addEventListener('resume', () => eventTarget.dispatchEvent(new Event('resume')));
nativeMediaRecorder.addEventListener('start', () => eventTarget.dispatchEvent(new Event('start')));
return {
get mimeType(): string {
return mimeType;
},
get state(): TRecordingState {
return nativeMediaRecorder.state;
},
pause(): void {
return nativeMediaRecorder.pause();
},
resume(): void {
return nativeMediaRecorder.resume();
},
start(timeslice?: number): void {
const [audioTrack] = mediaStream.getAudioTracks();
if (audioTrack !== undefined && nativeMediaRecorder.state === 'inactive') {
// Bug #19: Chrome does not expose the correct channelCount property right away.
const { channelCount, sampleRate } = audioTrack.getSettings();
if (channelCount === undefined) {
throw new Error('The channelCount is not defined.');
}
if (sampleRate === undefined) {
throw new Error('The sampleRate is not defined.');
}
let isRecording = false;
let isStopped = false;
// Bug #9: Chrome sometimes fires more than one dataavailable event while being inactive.
let pendingInvocations = 0;
let promisedDataViewElementTypeEncoderInstanceIdAndPort: TPromisedDataViewElementTypeEncoderInstanceIdAndPort =
instantiate(mimeType, sampleRate);
stopRecording = () => {
isStopped = true;
};
const removeEventListener = on(
nativeMediaRecorder,
'dataavailable'
)(({ data }) => {
pendingInvocations += 1;
const promisedArrayBuffer = data.arrayBuffer();
promisedDataViewElementTypeEncoderInstanceIdAndPort = promisedDataViewElementTypeEncoderInstanceIdAndPort.then(
async ({ dataView = null, elementType = null, encoderInstanceId, port }) => {
const arrayBuffer = await promisedArrayBuffer;
pendingInvocations -= 1;
const currentDataView =
dataView === null
? new MultiBufferDataView([arrayBuffer])
: new MultiBufferDataView([...dataView.buffers, arrayBuffer], dataView.byteOffset);
if (!isRecording && nativeMediaRecorder.state === 'recording' && !isStopped) {
const lengthAndValue = readVariableSizeInteger(currentDataView, 0);
if (lengthAndValue === null) {
return { dataView: currentDataView, elementType, encoderInstanceId, port };
}
const { value } = lengthAndValue;
if (value !== 172351395) {
return { dataView, elementType, encoderInstanceId, port };
}
isRecording = true;
}
const { currentElementType, offset, contents } = decodeWebMChunk(
currentDataView,
elementType,
channelCount
);
const remainingDataView =
offset < currentDataView.byteLength
? new MultiBufferDataView(currentDataView.buffers, currentDataView.byteOffset + offset)
: null;
contents.forEach((content) =>
port.postMessage(
content,
content.map(({ buffer }) => buffer)
)
);
if (pendingInvocations === 0 && (nativeMediaRecorder.state === 'inactive' || isStopped)) {
encode(encoderInstanceId, null).then((arrayBuffers) => {
dispatchDataAvailableEvent([...bufferedArrayBuffers, ...arrayBuffers]);
bufferedArrayBuffers.length = 0;
eventTarget.dispatchEvent(new Event('stop'));
});
port.postMessage([]);
port.close();
removeEventListener();
}
return { dataView: remainingDataView, elementType: currentElementType, encoderInstanceId, port };
}
);
});
if (timeslice !== undefined) {
promisedDataViewElementTypeEncoderInstanceIdAndPort.then(({ encoderInstanceId }) => {
if (isStopped) {
return;
}
promisedPartialRecording = requestNextPartialRecording(encoderInstanceId, timeslice);
});
}
}
nativeMediaRecorder.start(100);
},
stop
};
};
};