// Source code: https://github.com/katspaugh/wavesurfer.js/blob/fa2bcfe/src/plugins/record.ts /** * Record audio from the microphone, render a waveform and download the audio. */ import BasePlugin, { type BasePluginEvents, } from "wavesurfer.js/dist/base-plugin"; export type RecordPluginOptions = { mimeType?: MediaRecorderOptions["mimeType"]; audioBitsPerSecond?: MediaRecorderOptions["audioBitsPerSecond"]; }; export type RecordPluginEvents = BasePluginEvents & { startRecording: []; stopRecording: []; }; const MIME_TYPES = [ "audio/webm", "audio/wav", "audio/mpeg", "audio/mp4", "audio/mp3", ]; const findSupportedMimeType = () => MIME_TYPES.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)); class RecordPlugin extends BasePlugin { private mediaRecorder: MediaRecorder | null = null; private recordedUrl = ""; private savedCursorWidth = 1; private savedInteractive = true; public static create(options?: RecordPluginOptions) { return new RecordPlugin(options || {}); } private preventInteraction() { if (this.wavesurfer) { this.savedCursorWidth = this.wavesurfer.options.cursorWidth || 1; this.savedInteractive = this.wavesurfer.options.interact || true; this.wavesurfer.options.cursorWidth = 0; this.wavesurfer.options.interact = false; } } private restoreInteraction() { if (this.wavesurfer) { this.wavesurfer.options.cursorWidth = this.savedCursorWidth; this.wavesurfer.options.interact = this.savedInteractive; } } onInit() { this.preventInteraction(); } private loadBlob(data: Blob[], type: string) { const blob = new Blob(data, { type }); this.recordedUrl = URL.createObjectURL(blob); this.restoreInteraction(); this.wavesurfer?.load(this.recordedUrl); } render(stream: MediaStream): () => void { if (!this.wavesurfer) return () => undefined; const container = this.wavesurfer.getWrapper(); const canvas = document.createElement("canvas"); canvas.width = container.clientWidth; canvas.height = container.clientHeight; canvas.style.zIndex = "10"; container.appendChild(canvas); const canvasCtx = canvas.getContext("2d"); const audioContext = new AudioContext(); const source = audioContext.createMediaStreamSource(stream); const analyser = audioContext.createAnalyser(); analyser.fftSize = 2 ** 5; source.connect(analyser); const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); let animationId: number, previousTimeStamp: number; const MULTIPLIER = 1.2; const DATA_SIZE = 128.0; const BUFFER_SIZE = 2 ** 8; const dataBuffer = new Array(BUFFER_SIZE).fill(DATA_SIZE); const drawWaveform = (timeStamp: number) => { if (!canvasCtx) return; analyser.getByteTimeDomainData(dataArray); canvasCtx.clearRect(0, 0, canvas.width, canvas.height); canvasCtx.fillStyle = "#cc3347"; if (previousTimeStamp === undefined) { previousTimeStamp = timeStamp; dataBuffer.push(Math.min(...dataArray)); dataBuffer.splice(0, 1); } const elapsed = timeStamp - previousTimeStamp; if (elapsed > 10) { previousTimeStamp = timeStamp; dataBuffer.push(Math.min(...dataArray)); dataBuffer.splice(0, 1); } // Drawing const sliceWidth = canvas.width / dataBuffer.length; let xPos = 0; for (let i = 0; i < dataBuffer.length; i++) { const y = dataBuffer[i] + 2 > DATA_SIZE ? dataBuffer[i] : dataBuffer[i] / MULTIPLIER; const yPos = (canvas.height * y) / (2 * DATA_SIZE); const sliceHeight = ((1 - canvas.height) * y) / DATA_SIZE + canvas.height; canvasCtx.fillRect(xPos, yPos, (sliceWidth * 2) / 3, sliceHeight); xPos += sliceWidth; } animationId = requestAnimationFrame(drawWaveform); }; drawWaveform(0); return () => { if (animationId) { cancelAnimationFrame(animationId); } if (source) { source.disconnect(); source.mediaStream.getTracks().forEach((track) => track.stop()); } if (audioContext) { audioContext.close(); } canvas?.remove(); }; } private cleanUp() { this.stopRecording(); this.wavesurfer?.empty(); if (this.recordedUrl) { URL.revokeObjectURL(this.recordedUrl); this.recordedUrl = ""; } } public async startRecording(stream: MediaStream) { this.preventInteraction(); this.cleanUp(); const onStop = this.render(stream); const mediaRecorder = new MediaRecorder(stream, { mimeType: this.options.mimeType || findSupportedMimeType(), audioBitsPerSecond: this.options.audioBitsPerSecond, }); const recordedChunks: Blob[] = []; mediaRecorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) { recordedChunks.push(event.data); } }); mediaRecorder.addEventListener("stop", () => { onStop(); this.loadBlob(recordedChunks, mediaRecorder.mimeType); this.emit("stopRecording"); }); mediaRecorder.start(); this.emit("startRecording"); this.mediaRecorder = mediaRecorder; } public isRecording(): boolean { return this.mediaRecorder?.state === "recording"; } public stopRecording() { if (this.isRecording()) { this.mediaRecorder?.stop(); } } public getRecordedUrl(): string { return this.recordedUrl; } public destroy() { super.destroy(); this.cleanUp(); } } export default RecordPlugin;