extendable-media-recorder
Version:
An extendable drop-in replacement for the native MediaRecorder.
240 lines (188 loc) • 9.51 kB
text/typescript
import { encode, instantiate } from 'media-encoder-host';
import { addRecorderAudioWorkletModule, createRecorderAudioWorkletNode } from 'recorder-audio-worklet';
import {
AudioBuffer,
AudioBufferSourceNode,
AudioWorkletNode,
IAudioBuffer,
IMinimalAudioContext,
MediaStreamAudioSourceNode,
MinimalAudioContext,
addAudioWorkletModule
} from 'standardized-audio-context';
import { IAudioNodesAndEncoderInstanceId } from '../interfaces';
import { TRecordingState, TWebAudioMediaRecorderFactoryFactory } from '../types';
const ERROR_MESSAGE = 'Missing AudioWorklet support. Maybe this is not running in a secure context.';
// @todo This should live in a separate file.
const createPromisedAudioNodesEncoderInstanceIdAndPort = async (
audioBuffer: IAudioBuffer,
audioContext: IMinimalAudioContext,
channelCount: number,
mediaStream: MediaStream,
mimeType: string
) => {
const { encoderInstanceId, port } = await instantiate(mimeType, audioContext.sampleRate);
if (AudioWorkletNode === undefined) {
throw new Error(ERROR_MESSAGE);
}
const audioBufferSourceNode = new AudioBufferSourceNode(audioContext, { buffer: audioBuffer });
const mediaStreamAudioSourceNode = new MediaStreamAudioSourceNode(audioContext, { mediaStream });
const recorderAudioWorkletNode = createRecorderAudioWorkletNode(AudioWorkletNode, audioContext, { channelCount });
return { audioBufferSourceNode, encoderInstanceId, mediaStreamAudioSourceNode, port, recorderAudioWorkletNode };
};
export const createWebAudioMediaRecorderFactory: TWebAudioMediaRecorderFactoryFactory = (
createBlobEvent,
createInvalidModificationError,
createInvalidStateError,
createNotSupportedError
) => {
return (eventTarget, mediaStream, mimeType) => {
const sampleRate = mediaStream.getAudioTracks()[0]?.getSettings().sampleRate;
const audioContext = new MinimalAudioContext({ latencyHint: 'playback', sampleRate });
const length = Math.max(1024, Math.ceil(audioContext.baseLatency * audioContext.sampleRate));
const audioBuffer = new AudioBuffer({ length, sampleRate: audioContext.sampleRate });
const bufferedArrayBuffers: ArrayBuffer[] = [];
const promisedAudioWorkletModule = addRecorderAudioWorkletModule((url: string) => {
if (addAudioWorkletModule === undefined) {
throw new Error(ERROR_MESSAGE);
}
return addAudioWorkletModule(audioContext, url);
});
let abortRecording: null | (() => void) = null;
let intervalId: null | number = null;
let promisedAudioNodesAndEncoderInstanceId: null | Promise<IAudioNodesAndEncoderInstanceId> = null;
let promisedPartialRecording: null | Promise<void> = null;
let isAudioContextRunning = true;
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 (promisedAudioNodesAndEncoderInstanceId === null) {
bufferedArrayBuffers.push(...arrayBuffers);
} else {
dispatchDataAvailableEvent(arrayBuffers);
promisedPartialRecording = requestNextPartialRecording(encoderInstanceId, timeslice);
}
};
const resume = (): Promise<void> => {
isAudioContextRunning = true;
return audioContext.resume();
};
const stop = (): void => {
if (promisedAudioNodesAndEncoderInstanceId === null) {
return;
}
if (abortRecording !== null) {
mediaStream.removeEventListener('addtrack', abortRecording);
mediaStream.removeEventListener('removetrack', abortRecording);
}
if (intervalId !== null) {
clearTimeout(intervalId);
}
promisedAudioNodesAndEncoderInstanceId.then(
async ({ encoderInstanceId, mediaStreamAudioSourceNode, recorderAudioWorkletNode }) => {
if (promisedPartialRecording !== null) {
promisedPartialRecording.catch(() => {
/* @todo Only catch the errors caused by a duplicate call to encode. */
});
promisedPartialRecording = null;
}
await recorderAudioWorkletNode.stop();
mediaStreamAudioSourceNode.disconnect(recorderAudioWorkletNode);
const arrayBuffers = await encode(encoderInstanceId, null);
if (promisedAudioNodesAndEncoderInstanceId === null) {
await suspend();
}
dispatchDataAvailableEvent([...bufferedArrayBuffers, ...arrayBuffers]);
bufferedArrayBuffers.length = 0;
eventTarget.dispatchEvent(new Event('stop'));
}
);
promisedAudioNodesAndEncoderInstanceId = null;
};
const suspend = (): Promise<void> => {
isAudioContextRunning = false;
return audioContext.suspend();
};
suspend();
return {
get mimeType(): string {
return mimeType;
},
get state(): TRecordingState {
return promisedAudioNodesAndEncoderInstanceId === null ? 'inactive' : isAudioContextRunning ? 'recording' : 'paused';
},
pause(): void {
if (promisedAudioNodesAndEncoderInstanceId === null) {
throw createInvalidStateError();
}
if (isAudioContextRunning) {
suspend();
eventTarget.dispatchEvent(new Event('pause'));
}
},
resume(): void {
if (promisedAudioNodesAndEncoderInstanceId === null) {
throw createInvalidStateError();
}
if (!isAudioContextRunning) {
resume();
eventTarget.dispatchEvent(new Event('resume'));
}
},
start(timeslice?: number): void {
if (promisedAudioNodesAndEncoderInstanceId !== null) {
throw createInvalidStateError();
}
if (mediaStream.getVideoTracks().length > 0) {
throw createNotSupportedError();
}
eventTarget.dispatchEvent(new Event('start'));
const audioTracks = mediaStream.getAudioTracks();
const channelCount = audioTracks.length === 0 ? 2 : audioTracks[0].getSettings().channelCount ?? 2;
promisedAudioNodesAndEncoderInstanceId = Promise.all([
resume(),
promisedAudioWorkletModule.then(() =>
createPromisedAudioNodesEncoderInstanceIdAndPort(audioBuffer, audioContext, channelCount, mediaStream, mimeType)
)
]).then(
async ([
,
{ audioBufferSourceNode, encoderInstanceId, mediaStreamAudioSourceNode, port, recorderAudioWorkletNode }
]) => {
mediaStreamAudioSourceNode.connect(recorderAudioWorkletNode);
await new Promise((resolve) => {
audioBufferSourceNode.onended = resolve;
audioBufferSourceNode.connect(recorderAudioWorkletNode);
audioBufferSourceNode.start(audioContext.currentTime + length / audioContext.sampleRate);
});
audioBufferSourceNode.disconnect(recorderAudioWorkletNode);
await recorderAudioWorkletNode.record(port);
if (timeslice !== undefined) {
promisedPartialRecording = requestNextPartialRecording(encoderInstanceId, timeslice);
}
return { encoderInstanceId, mediaStreamAudioSourceNode, recorderAudioWorkletNode };
}
);
const tracks = mediaStream.getTracks();
abortRecording = () => {
stop();
eventTarget.dispatchEvent(new ErrorEvent('error', { error: createInvalidModificationError() }));
};
mediaStream.addEventListener('addtrack', abortRecording);
mediaStream.addEventListener('removetrack', abortRecording);
intervalId = setInterval(() => {
const currentTracks = mediaStream.getTracks();
if (
(currentTracks.length !== tracks.length || currentTracks.some((track, index) => track !== tracks[index])) &&
abortRecording !== null
) {
abortRecording();
}
}, 1000);
},
stop
};
};
};