mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-22 13:19:05 +00:00
Merge branch 'main' into jose/ui
This commit is contained in:
@@ -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;
|
||||
@@ -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 DisconnectedIndicator from "./disconnectedIndicator";
|
||||
import LiveTrancription from "./liveTranscription";
|
||||
import FinalSummary from "./finalSummary";
|
||||
import { Topic, FinalSummary as FinalSummaryType } from "./webSocketTypes";
|
||||
|
||||
type DashboardProps = {
|
||||
transcriptionText: string;
|
||||
finalSummary: FinalSummaryType;
|
||||
topics: Topic[];
|
||||
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}
|
||||
>
|
||||
⬇
|
||||
</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.summary && <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">
|
||||
{transcriptionText}
|
||||
</footer>
|
||||
<LiveTrancription text={transcriptionText} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
www/app/transcripts/disconnectedIndicator.tsx
Normal file
13
www/app/transcripts/disconnectedIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
www/app/transcripts/finalSummary.tsx
Normal file
12
www/app/transcripts/finalSummary.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
www/app/transcripts/liveTranscription.tsx
Normal file
11
www/app/transcripts/liveTranscription.tsx
Normal 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">
|
||||
{props.text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,11 @@ import useTranscript from "../useTranscript";
|
||||
import { useWebSockets } from "../useWebSockets";
|
||||
import useAudioDevice from "../useAudioDevice";
|
||||
import "../../styles/button.css";
|
||||
import getApi from "../../lib/getApi";
|
||||
|
||||
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") {
|
||||
@@ -22,8 +23,9 @@ const App = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const api = getApi();
|
||||
const transcript = useTranscript();
|
||||
const webRTC = useWebRTC(stream, transcript.response?.id);
|
||||
const webRTC = useWebRTC(stream, transcript.response?.id, api);
|
||||
const webSockets = useWebSockets(transcript.response?.id);
|
||||
const {
|
||||
loading,
|
||||
@@ -56,7 +58,6 @@ const App = () => {
|
||||
transcriptionText={webSockets.transcriptText}
|
||||
finalSummary={webSockets.finalSummary}
|
||||
topics={webSockets.topics}
|
||||
stream={stream}
|
||||
disconnected={disconnected}
|
||||
/>
|
||||
</>
|
||||
@@ -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) => {
|
||||
|
||||
23
www/app/transcripts/scrollToBottom.tsx
Normal file
23
www/app/transcripts/scrollToBottom.tsx
Normal 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;
|
||||
}}
|
||||
>
|
||||
⬇
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,26 @@
|
||||
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";
|
||||
import getApi from "../lib/getApi";
|
||||
|
||||
const useTranscript = () => {
|
||||
const [response, setResponse] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
type UseTranscript = {
|
||||
response: GetTranscript | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
createTranscript: () => void;
|
||||
};
|
||||
|
||||
const apiConfiguration = new Configuration({
|
||||
basePath: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
const api = new DefaultApi(apiConfiguration);
|
||||
const useTranscript = (): UseTranscript => {
|
||||
const [response, setResponse] = useState<GetTranscript | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const api = getApi();
|
||||
|
||||
const createTranscript = () => {
|
||||
setLoading(true);
|
||||
const requestParameters = {
|
||||
const requestParameters: V1TranscriptsCreateRequest = {
|
||||
createTranscript: {
|
||||
name: "Weekly All-Hands", // Hardcoded for now
|
||||
},
|
||||
@@ -1,28 +1,28 @@
|
||||
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,
|
||||
api: DefaultApi,
|
||||
): Peer => {
|
||||
const [peer, setPeer] = useState<Peer | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!stream || !transcriptId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiConfiguration = new Configuration({
|
||||
basePath: process.env.NEXT_PUBLIC_API_URL,
|
||||
});
|
||||
const api = new DefaultApi(apiConfiguration);
|
||||
let p: Peer = new Peer({ initiator: true, stream: stream });
|
||||
|
||||
let 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 +33,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 +41,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;
|
||||
@@ -1,10 +1,20 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Topic, FinalSummary, Status } from "./webSocketTypes";
|
||||
|
||||
export const useWebSockets = (transcriptId) => {
|
||||
const [transcriptText, setTranscriptText] = useState("");
|
||||
const [topics, setTopics] = useState([]);
|
||||
const [finalSummary, setFinalSummary] = useState("");
|
||||
const [status, setStatus] = useState("disconnected");
|
||||
type UseWebSockets = {
|
||||
transcriptText: string;
|
||||
topics: Topic[];
|
||||
finalSummary: FinalSummary;
|
||||
status: Status;
|
||||
};
|
||||
|
||||
export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
|
||||
const [transcriptText, setTranscriptText] = useState<string>("");
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [finalSummary, setFinalSummary] = useState<FinalSummary>({
|
||||
summary: "",
|
||||
});
|
||||
const [status, setStatus] = useState<Status>({ value: "disconnected" });
|
||||
|
||||
useEffect(() => {
|
||||
if (!transcriptId) return;
|
||||
@@ -40,7 +50,7 @@ export const useWebSockets = (transcriptId) => {
|
||||
break;
|
||||
|
||||
case "STATUS":
|
||||
setStatus(message.data.status);
|
||||
setStatus(message.data);
|
||||
break;
|
||||
|
||||
default:
|
||||
19
www/app/transcripts/webSocketTypes.tsx
Normal file
19
www/app/transcripts/webSocketTypes.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
export type Topic = {
|
||||
timestamp: number;
|
||||
title: string;
|
||||
transcript: string;
|
||||
summary: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
export type Transcript = {
|
||||
text: string;
|
||||
};
|
||||
|
||||
export type FinalSummary = {
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type Status = {
|
||||
value: string;
|
||||
};
|
||||
Reference in New Issue
Block a user