silence-aware-recorder
Version:
Audio control with silence detection.
236 lines (189 loc) • 6.66 kB
text/typescript
export type OnVolumeChange = (volume: number) => void;
export type OnDataAvailable = (data: Blob) => void | undefined;
export interface SilenceAwareRecorderOptions {
deviceId?: string;
minDecibels?: number;
onDataAvailable?: OnDataAvailable;
onVolumeChange?: OnVolumeChange;
setDeviceId?: (deviceId: string) => void;
silenceDetectionEnabled?: boolean;
silenceDuration?: number;
silentThreshold?: number;
stopRecorderOnSilence?: boolean;
timeSlice?: number;
}
class SilenceAwareRecorder {
private readonly silenceDetectionEnabled: boolean;
private readonly timeSlice: number;
private audioContext: AudioContext | null;
private mediaStreamSource: MediaStreamAudioSourceNode | null;
private analyser: AnalyserNode | null;
private mediaRecorder: MediaRecorder | null;
private silenceTimeout: ReturnType<typeof setTimeout> | null;
private readonly silenceThreshold: number;
private readonly silenceDuration: number;
private readonly minDecibels: number;
private readonly onVolumeChange?: OnVolumeChange;
private readonly onDataAvailable?: OnDataAvailable;
private isSilence: boolean;
private hasSoundStarted: boolean;
public deviceId: string | null;
public isRecording: boolean;
private readonly stopRecorderOnSilence: boolean;
private animationFrameId: number | null;
constructor({
onVolumeChange,
onDataAvailable,
silenceDuration = 2500,
silentThreshold = -50,
minDecibels = -100,
deviceId = 'default',
timeSlice = 250,
silenceDetectionEnabled = true,
stopRecorderOnSilence = false,
}: SilenceAwareRecorderOptions) {
this.silenceDetectionEnabled = silenceDetectionEnabled;
this.stopRecorderOnSilence = stopRecorderOnSilence;
this.timeSlice = timeSlice;
this.audioContext = null;
this.mediaStreamSource = null;
this.analyser = null;
this.mediaRecorder = null;
this.silenceTimeout = null;
this.silenceThreshold = silentThreshold;
this.silenceDuration = silenceDuration;
this.minDecibels = minDecibels;
this.onVolumeChange = onVolumeChange;
this.onDataAvailable = onDataAvailable;
this.isSilence = false;
this.hasSoundStarted = false;
this.deviceId = deviceId;
this.isRecording = false;
this.animationFrameId = null;
}
async startRecording(): Promise<void> {
if (this.isRecording) {
return;
}
try {
const stream = await this.getAudioStream();
this.setupAudioContext(stream);
this.setupMediaRecorder(stream);
this.isRecording = true;
this.checkForSilence();
} catch (err) {
console.error('Error getting audio stream:', err);
}
}
private async getAudioStream(): Promise<MediaStream> {
// eslint-disable-next-line no-undef
const constraints: MediaStreamConstraints = {
audio: this.deviceId ? { deviceId: { exact: this.deviceId } } : true,
video: false,
};
return navigator.mediaDevices.getUserMedia(constraints);
}
private setupAudioContext(stream: MediaStream): void {
this.audioContext = new AudioContext();
this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
this.analyser = this.audioContext.createAnalyser();
this.analyser.minDecibels = this.minDecibels;
this.mediaStreamSource.connect(this.analyser);
}
private setupMediaRecorder(stream: MediaStream): void {
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0 && !this.isSilence) {
this.onDataAvailable?.(event.data);
}
};
this.mediaRecorder.start(this.timeSlice);
}
async getAvailableDevices(): Promise<MediaDeviceInfo[]> {
return navigator.mediaDevices.enumerateDevices();
}
setDevice(deviceId: string): void {
if (this.deviceId !== deviceId) {
this.deviceId = deviceId;
if (this.mediaRecorder && this.mediaRecorder.state === 'recording') {
// If the recording is running, stop it before switching devices
this.stopRecording();
}
}
}
stopRecording(): void {
if (!this.isRecording) {
return;
}
if (this.mediaRecorder && this.hasSoundStarted && this.mediaRecorder.state === 'recording') {
this.mediaRecorder.requestData();
setTimeout(() => {
this.cleanUp();
}, 100); // adjust this delay as necessary
} else {
this.cleanUp();
}
if (this.silenceTimeout) {
clearTimeout(this.silenceTimeout);
this.silenceTimeout = null;
}
}
private cleanUp(): void {
if (this.mediaRecorder?.state === 'recording') {
this.mediaRecorder?.stop();
cancelAnimationFrame(this.animationFrameId!);
}
this.mediaRecorder?.stream?.getTracks().forEach((track) => track.stop());
this.audioContext?.close();
this.hasSoundStarted = false;
this.isRecording = false;
}
private checkForSilence(): void {
if (!this.mediaRecorder) {
throw new Error('MediaRecorder is not available');
}
if (!this.analyser) {
throw new Error('Analyser is not available');
}
const bufferLength = this.analyser.fftSize;
const amplitudeArray = new Float32Array(bufferLength || 0);
this.analyser.getFloatTimeDomainData(amplitudeArray);
const volume = this.computeVolume(amplitudeArray);
this.onVolumeChange?.(volume);
if (this.silenceDetectionEnabled) {
if (volume < this.silenceThreshold) {
if (!this.silenceTimeout) {
this.silenceTimeout = setTimeout(() => {
if (this.stopRecorderOnSilence) {
this.mediaRecorder?.stop();
}
this.isSilence = true;
this.silenceTimeout = null;
}, this.silenceDuration);
}
} else {
if (this.silenceTimeout) {
clearTimeout(this.silenceTimeout);
this.silenceTimeout = null;
}
if (this.isSilence) {
if (this.stopRecorderOnSilence) {
this.mediaRecorder.start(this.timeSlice);
}
this.isSilence = false;
}
if (!this.hasSoundStarted) {
this.hasSoundStarted = true;
}
}
}
this.animationFrameId = requestAnimationFrame(() => this.checkForSilence());
}
private computeVolume(amplitudeArray: Float32Array): number {
const values = amplitudeArray.reduce((sum, value) => sum + value * value, 0);
const average = Math.sqrt(values / amplitudeArray.length); // calculate rms
const volume = 20 * Math.log10(average); // convert to dB
return volume;
}
}
export default SilenceAwareRecorder;