Migrate to typescript

This commit is contained in:
Koper
2023-08-16 17:55:47 +07:00
parent 33ab54a626
commit ebbf47b895
24 changed files with 329 additions and 155 deletions

View File

@@ -1,128 +0,0 @@
// Override the startRecording method so we can pass the desired stream
// Checkout: https://github.com/katspaugh/wavesurfer.js/blob/fa2bcfe/src/plugins/record.ts
import RecordPlugin from "wavesurfer.js/dist/plugins/record";
const MIME_TYPES = [
"audio/webm",
"audio/wav",
"audio/mpeg",
"audio/mp4",
"audio/mp3",
];
const findSupportedMimeType = () =>
MIME_TYPES.find((mimeType) => MediaRecorder.isTypeSupported(mimeType));
class CustomRecordPlugin extends RecordPlugin {
static create(options) {
return new CustomRecordPlugin(options || {});
}
render(stream) {
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, previousTimeStamp;
const BUFFER_SIZE = 2 ** 8;
const dataBuffer = new Array(BUFFER_SIZE).fill(canvas.height);
const drawWaveform = (timeStamp) => {
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 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;
canvasCtx.fillRect(x, y, (sliceWidth * 2) / 3, sliceHeight);
x += sliceWidth;
}
animationId = requestAnimationFrame(drawWaveform);
};
drawWaveform();
return () => {
if (animationId) {
cancelAnimationFrame(animationId);
}
if (source) {
source.disconnect();
source.mediaStream.getTracks().forEach((track) => track.stop());
}
if (audioContext) {
audioContext.close();
}
canvas?.remove();
};
}
startRecording(stream) {
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 = [];
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;
}
}
export default CustomRecordPlugin;

View File

@@ -3,17 +3,29 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faChevronRight,
faChevronDown,
faLinkSlash,
} from "@fortawesome/free-solid-svg-icons";
import { formatTime } from "../lib/time";
import ScrollToBottom from "./scrollToBottom";
import FinalSummary from "./finalSummary";
import DisconnectedIndicator from "./disconnectedIndicator";
import LiveTrancription from "./liveTranscription";
import { TopicType, FinalSummaryType } from "./webSocketTypes";
type DashboardProps = {
transcriptionText: string;
finalSummary: FinalSummaryType;
topics: TopicType[];
disconnected: boolean;
};
export function Dashboard({
transcriptionText,
finalSummary,
topics,
disconnected,
}) {
const [openIndex, setOpenIndex] = useState(null);
const [autoscrollEnabled, setAutoscrollEnabled] = useState(true);
}: DashboardProps) {
const [openIndex, setOpenIndex] = useState<number | null>(null);
const [autoscrollEnabled, setAutoscrollEnabled] = useState<boolean>(true);
useEffect(() => {
if (autoscrollEnabled) scrollToBottom();
@@ -21,7 +33,10 @@ export function Dashboard({
const scrollToBottom = () => {
const topicsDiv = document.getElementById("topics-div");
topicsDiv.scrollTop = topicsDiv.scrollHeight;
if (!topicsDiv)
console.error("Could not find topics div to scroll to bottom");
else topicsDiv.scrollTop = topicsDiv.scrollHeight;
};
const handleScroll = (e) => {
@@ -34,18 +49,6 @@ export function Dashboard({
}
};
const formatTime = (seconds) => {
let hours = Math.floor(seconds / 3600);
let minutes = Math.floor((seconds % 3600) / 60);
let secs = Math.floor(seconds % 60);
let timeString = `${hours > 0 ? hours + ":" : ""}${minutes
.toString()
.padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
return timeString;
};
return (
<>
<div className="relative h-[60svh] w-3/4 flex flex-col">
@@ -57,16 +60,12 @@ export function Dashboard({
<div className="w-3/4 font-bold">Topic</div>
</div>
<div
className={`absolute right-5 w-10 h-10 ${
autoscrollEnabled ? "hidden" : "flex"
} ${
finalSummary ? "top-[49%]" : "bottom-1"
} justify-center items-center text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 animate-bounce rounded-xl border-slate-400 bg-[#3c82f638] text-[#3c82f6ed]`}
onClick={scrollToBottom}
>
&#11015;
</div>
<ScrollToBottom
visible={!autoscrollEnabled}
hasFinalSummary={finalSummary ? true : false}
handleScrollBottom={scrollToBottom}
/>
<div
id="topics-div"
className="py-2 overflow-y-auto"
@@ -99,26 +98,12 @@ export function Dashboard({
)}
</div>
{finalSummary && (
<div className="min-h-[200px] overflow-y-auto mt-2 p-2 bg-white temp-transcription rounded">
<h2>Final Summary</h2>
<p>{finalSummary.summary}</p>
</div>
)}
{finalSummary && <FinalSummary text={finalSummary.summary} />}
</div>
{disconnected && (
<div className="absolute top-0 left-0 w-full h-full bg-black opacity-50 flex justify-center items-center">
<div className="text-white text-2xl">
<FontAwesomeIcon icon={faLinkSlash} className="mr-2" />
Disconnected
</div>
</div>
)}
{disconnected && <DisconnectedIndicator />}
<footer className="h-[7svh] w-full bg-gray-800 text-white text-center py-4 text-2xl">
&nbsp;{transcriptionText}&nbsp;
</footer>
<LiveTrancription text={transcriptionText} />
</>
);
}

View File

@@ -0,0 +1,13 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faLinkSlash } from "@fortawesome/free-solid-svg-icons";
export default function DisconnectedIndicator() {
return (
<div className="absolute top-0 left-0 w-full h-full bg-black opacity-50 flex justify-center items-center">
<div className="text-white text-2xl">
<FontAwesomeIcon icon={faLinkSlash} className="mr-2" />
Disconnected
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
type FinalSummaryProps = {
text: string;
};
export default function FinalSummary(props: FinalSummaryProps) {
return (
<div className="min-h-[200px] overflow-y-auto mt-2 p-2 bg-white temp-transcription rounded">
<h2>Final Summary</h2>
<p>{props.text}</p>
</div>
);
}

View File

@@ -0,0 +1,11 @@
type LiveTranscriptionProps = {
text: string;
};
export default function LiveTrancription(props: LiveTranscriptionProps) {
return (
<div className="h-[7svh] w-full bg-gray-800 text-white text-center py-4 text-2xl">
&nbsp;{props.text}&nbsp;
</div>
);
}

View File

@@ -8,8 +8,8 @@ import { useWebSockets } from "../useWebSockets";
import "../../styles/button.css";
const App = () => {
const [stream, setStream] = useState(null);
const [disconnected, setDisconnected] = useState(false);
const [stream, setStream] = useState<MediaStream | null>(null);
const [disconnected, setDisconnected] = useState<boolean>(false);
useEffect(() => {
if (process.env.NEXT_PUBLIC_ENV === "development") {
@@ -46,7 +46,6 @@ const App = () => {
transcriptionText={webSockets.transcriptText}
finalSummary={webSockets.finalSummary}
topics={webSockets.topics}
stream={stream}
disconnected={disconnected}
/>
</div>

View File

@@ -8,7 +8,7 @@ import { faDownload } from "@fortawesome/free-solid-svg-icons";
import Dropdown from "react-dropdown";
import "react-dropdown/style.css";
import CustomRecordPlugin from "./CustomRecordPlugin";
import CustomRecordPlugin from "../lib/CustomRecordPlugin";
import { formatTime } from "../lib/time";
const AudioInputsDropdown = (props) => {

View File

@@ -0,0 +1,23 @@
type ScrollToBottomProps = {
visible: boolean;
hasFinalSummary: boolean;
handleScrollBottom: () => void;
};
export default function ScrollToBottom(props: ScrollToBottomProps) {
return (
<div
className={`absolute right-5 w-10 h-10 ${
props.visible ? "flex" : "hidden"
} ${
props.hasFinalSummary ? "top-[49%]" : "bottom-1"
} justify-center items-center text-2xl cursor-pointer opacity-70 hover:opacity-100 transition-opacity duration-200 animate-bounce rounded-xl border-slate-400 bg-[#3c82f638] text-[#3c82f6ed]`}
onClick={() => {
props.handleScrollBottom();
return false;
}}
>
&#11015;
</div>
);
}

View File

@@ -1,11 +1,19 @@
import { useEffect, useState } from "react";
import { DefaultApi } from "../api/apis/DefaultApi";
import { DefaultApi, V1TranscriptsCreateRequest } from "../api/apis/DefaultApi";
import { Configuration } from "../api/runtime";
import { GetTranscript } from "../api";
const useTranscript = () => {
const [response, setResponse] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
type UseTranscriptReturnType = {
response: GetTranscript | null;
loading: boolean;
error: string | null;
createTranscript: () => void;
};
const useTranscript = (): UseTranscriptReturnType => {
const [response, setResponse] = useState<GetTranscript | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const apiConfiguration = new Configuration({
basePath: process.env.NEXT_PUBLIC_API_URL,
@@ -14,7 +22,7 @@ const useTranscript = () => {
const createTranscript = () => {
setLoading(true);
const requestParameters = {
const requestParameters: V1TranscriptsCreateRequest = {
createTranscript: {
name: "Weekly All-Hands", // Hardcoded for now
},

View File

@@ -1,12 +1,16 @@
import { useEffect, useState } from "react";
import Peer from "simple-peer";
import { DefaultApi } from "../api/apis/DefaultApi";
import {
DefaultApi,
V1TranscriptRecordWebrtcRequest,
} from "../api/apis/DefaultApi";
import { Configuration } from "../api/runtime";
const useWebRTC = (stream, transcriptId) => {
const [data, setData] = useState({
peer: null,
});
const useWebRTC = (
stream: MediaStream | null,
transcriptId: string | null,
): Peer => {
const [peer, setPeer] = useState<Peer | null>(null);
useEffect(() => {
if (!stream || !transcriptId) {
@@ -18,11 +22,11 @@ const useWebRTC = (stream, transcriptId) => {
});
const api = new DefaultApi(apiConfiguration);
let peer = new Peer({ initiator: true, stream: stream });
let p: Peer = new Peer({ initiator: true, stream: stream });
peer.on("signal", (data) => {
p.on("signal", (data: any) => {
if ("sdp" in data) {
const requestParameters = {
const requestParameters: V1TranscriptRecordWebrtcRequest = {
transcriptId: transcriptId,
rtcOffer: {
sdp: data.sdp,
@@ -33,7 +37,7 @@ const useWebRTC = (stream, transcriptId) => {
api
.v1TranscriptRecordWebrtc(requestParameters)
.then((answer) => {
peer.signal(answer);
p.signal(answer);
})
.catch((err) => {
console.error("WebRTC signaling error:", err);
@@ -41,17 +45,17 @@ const useWebRTC = (stream, transcriptId) => {
}
});
peer.on("connect", () => {
p.on("connect", () => {
console.log("WebRTC connected");
setData((prevData) => ({ ...prevData, peer: peer }));
setPeer(p);
});
return () => {
peer.destroy();
p.destroy();
};
}, [stream, transcriptId]);
return data;
return peer;
};
export default useWebRTC;

View File

@@ -1,10 +1,22 @@
import { useEffect, useState } from "react";
import { TopicType, FinalSummaryType, StatusType } from "./webSocketTypes";
export const useWebSockets = (transcriptId) => {
const [transcriptText, setTranscriptText] = useState("");
const [topics, setTopics] = useState([]);
const [finalSummary, setFinalSummary] = useState("");
const [status, setStatus] = useState("disconnected");
type UseWebSocketsReturnType = {
transcriptText: string;
topics: TopicType[];
finalSummary: FinalSummaryType;
status: StatusType;
};
export const useWebSockets = (
transcriptId: string | null,
): UseWebSocketsReturnType => {
const [transcriptText, setTranscriptText] = useState<string>("");
const [topics, setTopics] = useState<TopicType[]>([]);
const [finalSummary, setFinalSummary] = useState<FinalSummaryType>({
summary: "",
});
const [status, setStatus] = useState<StatusType>({ value: "disconnected" });
useEffect(() => {
if (!transcriptId) return;
@@ -40,7 +52,7 @@ export const useWebSockets = (transcriptId) => {
break;
case "STATUS":
setStatus(message.data.status);
setStatus(message.data);
break;
default:

View File

@@ -0,0 +1,19 @@
export type TopicType = {
timestamp: number;
title: string;
transcript: string;
summary: string;
id: string;
};
export type TranscriptType = {
text: string;
};
export type FinalSummaryType = {
summary: string;
};
export type StatusType = {
value: string;
};