From c970fa027afb5beb68cce8869c9a41c36269ca9b Mon Sep 17 00:00:00 2001 From: Jose B Date: Fri, 18 Aug 2023 16:19:46 -0500 Subject: [PATCH] more TS --- www/app/lib/custom-plugins/record.ts | 99 ++++++++++++++++--- .../transcripts/{recorder.js => recorder.tsx} | 72 ++++++++------ www/tsconfig.json | 3 +- 3 files changed, 132 insertions(+), 42 deletions(-) rename www/app/transcripts/{recorder.js => recorder.tsx} (77%) diff --git a/www/app/lib/custom-plugins/record.ts b/www/app/lib/custom-plugins/record.ts index aa85a812..2d42e80f 100644 --- a/www/app/lib/custom-plugins/record.ts +++ b/www/app/lib/custom-plugins/record.ts @@ -1,6 +1,21 @@ // 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", @@ -12,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(); @@ -35,12 +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(DATA_SIZE); - const drawWaveform = (timeStamp) => { + const drawWaveform = (timeStamp: number) => { if (!canvasCtx) return; analyser.getByteTimeDomainData(dataArray); @@ -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/recorder.js b/www/app/transcripts/recorder.tsx similarity index 77% rename from www/app/transcripts/recorder.js rename to www/app/transcripts/recorder.tsx index 9ee68096..f7b7422c 100644 --- a/www/app/transcripts/recorder.js +++ b/www/app/transcripts/recorder.tsx @@ -1,19 +1,22 @@ import React, { useRef, useEffect, useState } from "react"; import WaveSurfer from "wavesurfer.js"; -import CustomRecordPlugin from "../lib/custom-plugins/record"; +import RecordPlugin from "../lib/custom-plugins/record"; import CustomRegionsPlugin from "../lib/custom-plugins/regions"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faDownload } from "@fortawesome/free-solid-svg-icons"; -import Dropdown from "react-dropdown"; +import Dropdown, { Option } from "react-dropdown"; import "react-dropdown/style.css"; import { formatTime } from "../lib/time"; -const AudioInputsDropdown = (props) => { - const [ddOptions, setDdOptions] = useState([]); +const AudioInputsDropdown: React.FC<{ + setDeviceId: React.Dispatch>; + disabled: boolean; +}> = (props) => { + const [ddOptions, setDdOptions] = useState>([]); useEffect(() => { const init = async () => { @@ -35,8 +38,8 @@ const AudioInputsDropdown = (props) => { init(); }, []); - const handleDropdownChange = (e) => { - props.setDeviceId(e.value); + const handleDropdownChange = (option: Option) => { + props.setDeviceId(option.value); }; return ( @@ -49,24 +52,27 @@ const AudioInputsDropdown = (props) => { ); }; -export default function Recorder(props) { +export default function Recorder(props: any) { 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 [waveRegions, setWaveRegions] = useState(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); + const [waveRegions, setWaveRegions] = useState( + null, + ); const [activeTopic, setActiveTopic] = props.useActiveTopic; const topicsRef = useRef(props.topics); 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({ @@ -92,7 +98,7 @@ export default function Recorder(props) { }); _wavesurfer.on("timeupdate", setCurrentTime); - setRecord(_wavesurfer.registerPlugin(CustomRecordPlugin.create())); + setRecord(_wavesurfer.registerPlugin(RecordPlugin.create())); setWaveRegions(_wavesurfer.registerPlugin(CustomRegionsPlugin.create())); setWavesurfer(_wavesurfer); @@ -115,7 +121,9 @@ export default function Recorder(props) { waveRegions.clearRegions(); for (let topic of topicsRef.current) { const content = document.createElement("div"); - content.style = ` + content.setAttribute( + "style", + ` position: absolute; border-left: solid 1px orange; padding: 0 2px 0 5px; @@ -129,15 +137,16 @@ export default function Recorder(props) { overflow: hidden; text-overflow: ellipsis; transition: width 100ms linear; - `; + `, + ); content.onmouseover = () => { content.style.backgroundColor = "orange"; - content.style.zIndex = 999; + content.style.zIndex = "999"; content.style.width = "300px"; }; content.onmouseout = () => { content.style.backgroundColor = "white"; - content.style.zIndex = 0; + content.style.zIndex = "0"; content.style.width = "100px"; }; content.textContent = topic.title; @@ -151,7 +160,7 @@ export default function Recorder(props) { region.on("click", (e) => { e.stopPropagation(); setActiveTopic(topic); - wavesurfer.setTime(region.start); + wavesurfer?.setTime(region.start); }); } }; @@ -160,8 +169,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"; renderMarkers(); }); @@ -170,13 +181,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; @@ -186,7 +197,7 @@ export default function Recorder(props) { useEffect(() => { if (activeTopic) { - wavesurfer.setTime(activeTopic.timestamp); + wavesurfer?.setTime(activeTopic.timestamp); } }, [activeTopic]); @@ -197,11 +208,12 @@ 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 navigator.mediaDevices.getUserMedia({ audio: { - deviceId, + deviceId: deviceId as string, noiseSuppression: false, echoCancellation: false, }, diff --git a/www/tsconfig.json b/www/tsconfig.json index 9c9b16c2..0e1b89ae 100644 --- a/www/tsconfig.json +++ b/www/tsconfig.json @@ -18,7 +18,8 @@ "name": "next" } ], - "strictNullChecks": true + "strictNullChecks": true, + "downlevelIteration": true }, "include": ["next-env.d.ts", ".next/types/**/*.ts", "**/*.ts", "**/*.tsx"], "exclude": ["node_modules"]