diff --git a/www/app/lib/CustomRecordPlugin.js b/www/app/lib/custom-plugins/record.ts similarity index 51% rename from www/app/lib/CustomRecordPlugin.js rename to www/app/lib/custom-plugins/record.ts index 7e29ea7c..2d42e80f 100644 --- a/www/app/lib/CustomRecordPlugin.js +++ b/www/app/lib/custom-plugins/record.ts @@ -1,7 +1,21 @@ -// Override the startRecording method so we can pass the desired stream -// Checkout: https://github.com/katspaugh/wavesurfer.js/blob/fa2bcfe/src/plugins/record.ts +// 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 RecordPlugin from "wavesurfer.js/dist/plugins/record"; +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", @@ -13,11 +27,44 @@ const MIME_TYPES = [ const findSupportedMimeType = () => MIME_TYPES.find((mimeType) => MediaRecorder.isTypeSupported(mimeType)); -class CustomRecordPlugin extends RecordPlugin { - static create(options) { - return new CustomRecordPlugin(options || {}); +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 || {}); } - render(stream) { + + 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(); @@ -36,11 +83,12 @@ class CustomRecordPlugin extends RecordPlugin { const bufferLength = analyser.frequencyBinCount; const dataArray = new Uint8Array(bufferLength); - let animationId, previousTimeStamp; + let animationId: number, previousTimeStamp: number; + const DATA_SIZE = 128.0; const BUFFER_SIZE = 2 ** 8; - const dataBuffer = new Array(BUFFER_SIZE).fill(canvas.height); + const dataBuffer = new Array(BUFFER_SIZE).fill(DATA_SIZE); - const drawWaveform = (timeStamp) => { + const drawWaveform = (timeStamp: number) => { if (!canvasCtx) return; analyser.getByteTimeDomainData(dataArray); @@ -64,9 +112,9 @@ class CustomRecordPlugin extends RecordPlugin { let x = 0; for (let i = 0; i < dataBuffer.length; i++) { - const valueNormalized = dataBuffer[i] / canvas.height; - const y = (valueNormalized * canvas.height) / 2; - const sliceHeight = canvas.height + 1 - y * 2; + const y = (canvas.height * dataBuffer[i]) / (2 * DATA_SIZE); + const sliceHeight = + ((1 - canvas.height) * dataBuffer[i]) / DATA_SIZE + canvas.height; canvasCtx.fillRect(x, y, (sliceWidth * 2) / 3, sliceHeight); x += sliceWidth; @@ -75,7 +123,7 @@ class CustomRecordPlugin extends RecordPlugin { animationId = requestAnimationFrame(drawWaveform); }; - drawWaveform(); + drawWaveform(0); return () => { if (animationId) { @@ -94,7 +142,17 @@ class CustomRecordPlugin extends RecordPlugin { canvas?.remove(); }; } - startRecording(stream) { + + 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(); @@ -103,7 +161,7 @@ class CustomRecordPlugin extends RecordPlugin { mimeType: this.options.mimeType || findSupportedMimeType(), audioBitsPerSecond: this.options.audioBitsPerSecond, }); - const recordedChunks = []; + const recordedChunks: Blob[] = []; mediaRecorder.addEventListener("dataavailable", (event) => { if (event.data.size > 0) { @@ -123,6 +181,25 @@ class CustomRecordPlugin extends RecordPlugin { 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 CustomRecordPlugin; +export default RecordPlugin; diff --git a/www/app/transcripts/new/page.tsx b/www/app/transcripts/new/page.tsx index 9446171a..a5e68f84 100644 --- a/www/app/transcripts/new/page.tsx +++ b/www/app/transcripts/new/page.tsx @@ -36,12 +36,7 @@ const App = () => { } = useAudioDevice(); return ( -
-
-

Reflector

-

Capture The Signal, Not The Noise

-
- +
{permissionOk ? ( <> { - const [ddOptions, setDdOptions] = useState([]); +const AudioInputsDropdown = (props: { + audioDevices: Option[]; + setDeviceId: React.Dispatch>; + disabled: boolean; +}) => { + const [ddOptions, setDdOptions] = useState([]); useEffect(() => { setDdOptions(props.audioDevices); @@ -21,8 +25,8 @@ const AudioInputsDropdown = (props) => { ); }, [props.audioDevices]); - const handleDropdownChange = (e) => { - props.setDeviceId(e.value); + const handleDropdownChange = (option: Option) => { + props.setDeviceId(option.value); }; return ( @@ -36,18 +40,19 @@ const AudioInputsDropdown = (props) => { }; export default function Recorder(props) { - const waveformRef = useRef(); - const [wavesurfer, setWavesurfer] = useState(null); - const [record, setRecord] = useState(null); - const [isRecording, setIsRecording] = useState(false); - const [isPlaying, setIsPlaying] = useState(false); - const [deviceId, setDeviceId] = useState(null); - const [currentTime, setCurrentTime] = useState(0); - const [timeInterval, setTimeInterval] = useState(null); - const [duration, setDuration] = useState(0); + const waveformRef = useRef(null); + const [wavesurfer, setWavesurfer] = useState(null); + const [record, setRecord] = useState(null); + const [isRecording, setIsRecording] = useState(false); + const [isPlaying, setIsPlaying] = useState(false); + const [deviceId, setDeviceId] = useState(null); + const [currentTime, setCurrentTime] = useState(0); + const [timeInterval, setTimeInterval] = useState(null); + const [duration, setDuration] = useState(0); useEffect(() => { - document.getElementById("play-btn").disabled = true; + const playBtn = document.getElementById("play-btn"); + if (playBtn) playBtn.setAttribute("disabled", "true"); if (waveformRef.current) { const _wavesurfer = WaveSurfer.create({ @@ -72,7 +77,7 @@ export default function Recorder(props) { }); _wavesurfer.on("timeupdate", setCurrentTime); - setRecord(_wavesurfer.registerPlugin(CustomRecordPlugin.create())); + setRecord(_wavesurfer.registerPlugin(RecordPlugin.create())); setWavesurfer(_wavesurfer); return () => { _wavesurfer.destroy(); @@ -86,8 +91,10 @@ export default function Recorder(props) { if (record) { return record.on("stopRecording", () => { const link = document.getElementById("download-recording"); - link.href = record.getRecordedUrl(); - link.download = "reflector-recording.webm"; + if (!link) return; + + link.setAttribute("href", record.getRecordedUrl()); + link.setAttribute("download", "reflector-recording.webm"); link.style.visibility = "visible"; }); } @@ -95,13 +102,13 @@ export default function Recorder(props) { useEffect(() => { if (isRecording) { - const interval = setInterval(() => { + const interval = window.setInterval(() => { setCurrentTime((prev) => prev + 1); }, 1000); setTimeInterval(interval); return () => clearInterval(interval); } else { - clearInterval(timeInterval); + clearInterval(timeInterval as number); setCurrentTime((prev) => { setDuration(prev); return 0; @@ -116,7 +123,8 @@ export default function Recorder(props) { props.onStop(); record.stopRecording(); setIsRecording(false); - document.getElementById("play-btn").disabled = false; + const playBtn = document.getElementById("play-btn"); + if (playBtn) playBtn.removeAttribute("disabled"); } else { const stream = await props.getAudioStream(deviceId); props.setStream(stream); @@ -133,9 +141,8 @@ export default function Recorder(props) { const timeLabel = () => { if (isRecording) return formatTime(currentTime); - else if (duration) - return `${formatTime(currentTime)}/${formatTime(duration)}`; - else ""; + if (duration) return `${formatTime(currentTime)}/${formatTime(duration)}`; + return ""; }; return ( diff --git a/www/app/transcripts/useAudioDevice.js b/www/app/transcripts/useAudioDevice.ts similarity index 81% rename from www/app/transcripts/useAudioDevice.js rename to www/app/transcripts/useAudioDevice.ts index 4432648e..da1dbe49 100644 --- a/www/app/transcripts/useAudioDevice.js +++ b/www/app/transcripts/useAudioDevice.ts @@ -1,8 +1,10 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; + +import { Option } from "react-dropdown"; const useAudioDevice = () => { const [permissionOk, setPermissionOk] = useState(false); - const [audioDevices, setAudioDevices] = useState([]); + const [audioDevices, setAudioDevices] = useState([]); const [loading, setLoading] = useState(true); const requestPermission = () => { @@ -22,7 +24,9 @@ const useAudioDevice = () => { }); }; - const getAudioStream = async (deviceId) => { + const getAudioStream = async ( + deviceId: string, + ): Promise => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: { @@ -39,7 +43,7 @@ const useAudioDevice = () => { } }; - const updateDevices = async () => { + const updateDevices = async (): Promise => { const devices = await navigator.mediaDevices.enumerateDevices(); const _audioDevices = devices .filter( @@ -51,10 +55,6 @@ const useAudioDevice = () => { setAudioDevices(_audioDevices); }; - useEffect(() => { - requestPermission(); - }, []); - return { permissionOk, audioDevices,