Updated the Speaking to also work the with exam session persistence

This commit is contained in:
Tiago Ribeiro
2024-02-07 17:15:41 +00:00
parent 65fe1ec8ed
commit 2a9e204041
8 changed files with 177 additions and 58 deletions

View File

@@ -24,14 +24,15 @@ export default function InteractiveSpeaking({
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [promptIndex, setPromptIndex] = useState(0);
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]); const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => { useEffect(() => {
if (updateIndex) updateIndex(promptIndex); if (updateIndex) updateIndex(questionIndex);
}, [promptIndex, updateIndex]); }, [questionIndex, updateIndex]);
useEffect(() => { useEffect(() => {
if (hasExamEnded) { if (hasExamEnded) {
@@ -59,14 +60,14 @@ export default function InteractiveSpeaking({
}, [isRecording]); }, [isRecording]);
useEffect(() => { useEffect(() => {
if (promptIndex === answers.length - 1) { if (questionIndex === answers.length - 1) {
setMediaBlob(answers[promptIndex].blob); setMediaBlob(answers[questionIndex].blob);
} }
}, [answers, promptIndex]); }, [answers, questionIndex]);
const saveAnswer = () => { const saveAnswer = () => {
const answer = { const answer = {
prompt: prompts[promptIndex].text, prompt: prompts[questionIndex].text,
blob: mediaBlob!, blob: mediaBlob!,
}; };
@@ -82,8 +83,8 @@ export default function InteractiveSpeaking({
</div> </div>
{prompts && prompts.length > 0 && ( {prompts && prompts.length > 0 && (
<div className="flex flex-col gap-4 w-full items-center"> <div className="flex flex-col gap-4 w-full items-center">
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl"> <video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
<source src={prompts[promptIndex].video_url} /> <source src={prompts[questionIndex].video_url} />
</video> </video>
</div> </div>
)} )}
@@ -91,7 +92,7 @@ export default function InteractiveSpeaking({
<ReactMediaRecorder <ReactMediaRecorder
audio audio
key={promptIndex} key={questionIndex}
onStop={(blob) => setMediaBlob(blob)} onStop={(blob) => setMediaBlob(blob)}
render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => ( render={({status, startRecording, stopRecording, pauseRecording, resumeRecording, clearBlobUrl, mediaBlobUrl}) => (
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center"> <div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
@@ -227,8 +228,8 @@ export default function InteractiveSpeaking({
disabled={!mediaBlob} disabled={!mediaBlob}
onClick={() => { onClick={() => {
saveAnswer(); saveAnswer();
if (promptIndex + 1 < prompts.length) { if (questionIndex + 1 < prompts.length) {
setPromptIndex((prev) => prev + 1); setQuestionIndex(questionIndex + 1);
return; return;
} }
onNext({ onNext({
@@ -236,7 +237,7 @@ export default function InteractiveSpeaking({
solutions: [ solutions: [
...answers, ...answers,
{ {
prompt: prompts[promptIndex].text, prompt: prompts[questionIndex].text,
blob: mediaBlob!, blob: mediaBlob!,
}, },
], ],
@@ -245,7 +246,7 @@ export default function InteractiveSpeaking({
}); });
}} }}
className="max-w-[200px] self-end w-full"> className="max-w-[200px] self-end w-full">
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"} {questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import Button from "../Low/Button"; import Button from "../Low/Button";
import useExamStore from "@/stores/examStore"; import useExamStore from "@/stores/examStore";
import {downloadBlob} from "@/utils/evaluation";
import axios from "axios";
const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
ssr: false, ssr: false,
}); });
export default function Speaking({id, title, text, video_url, type, prompts, onNext, onBack}: SpeakingExercise & CommonProps) { export default function Speaking({id, title, text, video_url, type, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) {
const [recordingDuration, setRecordingDuration] = useState(0); const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false); const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>(); const [mediaBlob, setMediaBlob] = useState<string>();
const [audioURL, setAudioURL] = useState<string>();
const [isLoading, setIsLoading] = useState(false);
const hasExamEnded = useExamStore((state) => state.hasExamEnded); const hasExamEnded = useExamStore((state) => state.hasExamEnded);
useEffect(() => { const saveToStorage = async () => {
if (hasExamEnded) { if (mediaBlob && mediaBlob.startsWith("blob")) {
onNext({ const blobBuffer = await downloadBlob(mediaBlob);
exercise: id, const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0}, const seed = Math.random().toString().replace("0.", "");
type,
}); const formData = new FormData();
formData.append("audio", audioFile, `${seed}.wav`);
formData.append("root", "speaking_recordings");
const config = {
headers: {
"Content-Type": "audio/wav",
},
};
const response = await axios.post<{path: string}>("/api/storage/insert", formData, config);
if (audioURL) await axios.post("/api/storage/delete", {path: audioURL});
return response.data.path;
} }
return undefined;
};
useEffect(() => {
if (userSolutions.length > 0) {
const {solution} = userSolutions[0] as {solution?: string};
if (solution && !mediaBlob) setMediaBlob(solution);
if (solution && !solution.startsWith("blob")) setAudioURL(solution);
}
}, [userSolutions, mediaBlob]);
useEffect(() => {
if (hasExamEnded) next();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasExamEnded]); }, [hasExamEnded]);
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
}; };
}, [isRecording]); }, [isRecording]);
const next = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onNext({
exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
const back = async () => {
setIsLoading(true);
const storagePath = await saveToStorage();
setIsLoading(false);
onBack({
exercise: id,
solutions: storagePath ? [{id, solution: storagePath}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
return ( return (
<div className="flex flex-col h-full w-full gap-9"> <div className="flex flex-col h-full w-full gap-9">
<div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16"> <div className="flex flex-col w-full gap-2 bg-mti-gray-smoke rounded-xl py-8 px-16">
@@ -89,7 +145,7 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
<div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center"> <div className="w-full p-4 px-8 bg-transparent border-2 border-mti-gray-platinum rounded-2xl flex-col gap-8 items-center">
<p className="text-base font-normal">Record your answer:</p> <p className="text-base font-normal">Record your answer:</p>
<div className="flex gap-8 items-center justify-center py-8"> <div className="flex gap-8 items-center justify-center py-8">
{status === "idle" && ( {status === "idle" && !mediaBlob && (
<> <>
<div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" /> <div className="w-full h-2 max-w-4xl bg-mti-gray-smoke rounded-full" />
{status === "idle" && ( {status === "idle" && (
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
</div> </div>
</> </>
)} )}
{status === "stopped" && mediaBlobUrl && ( {((status === "stopped" && mediaBlobUrl) || (status === "idle" && mediaBlob)) && (
<> <>
<Waveform audio={mediaBlobUrl} waveColor="#FCDDEC" progressColor="#EF5DA8" /> <Waveform audio={mediaBlobUrl ? mediaBlobUrl : mediaBlob!} waveColor="#FCDDEC" progressColor="#EF5DA8" />
<div className="flex gap-4 items-center"> <div className="flex gap-4 items-center">
<BsTrashFill <BsTrashFill
className="text-mti-gray-cool cursor-pointer w-5 h-5" className="text-mti-gray-cool cursor-pointer w-5 h-5"
@@ -200,32 +256,10 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
/> />
<div className="self-end flex justify-between w-full gap-8"> <div className="self-end flex justify-between w-full gap-8">
<Button <Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Back Back
</Button> </Button>
<Button <Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
color="purple"
disabled={!mediaBlob}
onClick={() =>
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
score: {correct: 1, total: 1, missing: 0},
type,
})
}
className="max-w-[200px] self-end w-full">
Next Next
</Button> </Button>
</div> </div>

View File

@@ -80,14 +80,14 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
return ( return (
<> <>
<div className="flex flex-col h-full w-full gap-8 items-center"> <div className="flex flex-col h-full w-full gap-8 items-center">
<ModuleTitle {/* <ModuleTitle
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)} label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
minTimer={exam.minTimer} minTimer={exam.minTimer}
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex} exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
module="speaking" module="speaking"
totalExercises={countExercises(exam.exercises)} totalExercises={countExercises(exam.exercises)}
disableTimer={showSolutions} disableTimer={showSolutions}
/> /> */}
{exerciseIndex > -1 && {exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length && exerciseIndex < exam.exercises.length &&
!showSolutions && !showSolutions &&

View File

@@ -98,13 +98,22 @@ export default function ExamPage({page}: Props) {
}, [exams, setUserSolutions, userSolutions]); }, [exams, setUserSolutions, userSolutions]);
useEffect(() => { useEffect(() => {
if (sessionId.length > 0 && userSolutions.length > 0 && selectedModules.length > 0 && exams.length > 0 && !!exam && timeSpent > 0) if (
sessionId.length > 0 &&
userSolutions.length > 0 &&
selectedModules.length > 0 &&
exams.length > 0 &&
!!exam &&
timeSpent > 0 &&
!showSolutions &&
moduleIndex < selectedModules.length
)
saveSession(); saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]); }, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
useEffect(() => { useEffect(() => {
if (timeSpent % 20 === 0 && timeSpent > 0) saveSession(); if (timeSpent % 20 === 0 && timeSpent > 0 && moduleIndex < selectedModules.length && !showSolutions) saveSession();
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeSpent]); }, [timeSpent]);
@@ -282,7 +291,6 @@ export default function ExamPage({page}: Props) {
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]); setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
setModuleIndex(moduleIndex + 1); setModuleIndex(moduleIndex + 1);
// TODO: Solve the issue for the listening where it should start with -1
setPartIndex(-1); setPartIndex(-1);
setExerciseIndex(-1); setExerciseIndex(-1);
setQuestionIndex(0); setQuestionIndex(0);

View File

@@ -4,6 +4,7 @@ import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore"; import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next"; import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session"; import {sessionOptions} from "@/lib/session";
import {Session} from "@/hooks/useSessions";
const db = getFirestore(app); const db = getFirestore(app);

View File

@@ -0,0 +1,28 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {app} from "@/firebase";
import {getFirestore, doc, getDoc, deleteDoc} from "firebase/firestore";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import {Session} from "@/hooks/useSessions";
import {deleteObject, getStorage, ref} from "firebase/storage";
const storage = getStorage(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method === "POST") return post(req, res);
}
async function post(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const {path} = req.body as {path: string};
await deleteObject(ref(storage, path));
return res.status(200).json({ok: true});
}

View File

@@ -0,0 +1,46 @@
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type {NextApiRequest, NextApiResponse} from "next";
import {withIronSessionApiRoute} from "iron-session/next";
import {sessionOptions} from "@/lib/session";
import axios, {AxiosResponse} from "axios";
import formidable from "formidable-serverless";
import {getDownloadURL, ref, uploadBytes} from "firebase/storage";
import fs from "fs";
import {app, storage} from "@/firebase";
import {doc, getDoc, getFirestore, setDoc} from "firebase/firestore";
import {Stat} from "@/interfaces/user";
import {speakingReverseMarking} from "@/utils/score";
const db = getFirestore(app);
export default withIronSessionApiRoute(handler, sessionOptions);
async function handler(req: NextApiRequest, res: NextApiResponse) {
if (!req.session.user) {
res.status(401).json({ok: false});
return;
}
const form = formidable({keepExtensions: true});
await form.parse(req, async (err: any, fields: any, files: any) => {
if (err) {
console.log(err);
return res.status(500).json({ok: false});
}
const audioFile = files.audio;
console.log({fields}, (audioFile as any).path);
const audioFileRef = ref(storage, `${fields.root}/${(audioFile as any).path.split("/").pop()!.replace("upload_", "")}`);
const binary = fs.readFileSync((audioFile as any).path).buffer;
const snapshot = await uploadBytes(audioFileRef, binary);
const path = await getDownloadURL(snapshot.ref);
res.status(200).json({path});
});
}
export const config = {
api: {
bodyParser: false,
},
};

View File

@@ -45,16 +45,17 @@ export const evaluateSpeakingAnswer = async (exercise: SpeakingExercise | Intera
} }
}; };
const downloadBlob = async (url: string): Promise<Buffer> => { export const downloadBlob = async (url: string): Promise<Buffer> => {
const blobResponse = await axios.get(url, {responseType: "arraybuffer"}); const blobResponse = await axios.get(url, {responseType: "arraybuffer"});
return Buffer.from(blobResponse.data, "binary"); return Buffer.from(blobResponse.data, "binary");
}; };
const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution, id: string) => { const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId: string, solution: UserSolution, id: string) => {
const formData = new FormData();
const audioBlob = await downloadBlob(solution.solutions[0].solution.trim()); const audioBlob = await downloadBlob(solution.solutions[0].solution.trim());
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"}); const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
const formData = new FormData();
formData.append("audio", audioFile, "audio.wav"); formData.append("audio", audioFile, "audio.wav");
const evaluationQuestion = const evaluationQuestion =
@@ -64,7 +65,7 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
const config = { const config = {
headers: { headers: {
"Content-Type": "audio/mp3", "Content-Type": "audio/wav",
}, },
}; };