Updated the Speaking to also work the with exam session persistence
This commit is contained in:
@@ -24,14 +24,15 @@ export default function InteractiveSpeaking({
|
||||
const [recordingDuration, setRecordingDuration] = useState(0);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [promptIndex, setPromptIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
|
||||
|
||||
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (updateIndex) updateIndex(promptIndex);
|
||||
}, [promptIndex, updateIndex]);
|
||||
if (updateIndex) updateIndex(questionIndex);
|
||||
}, [questionIndex, updateIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
@@ -59,14 +60,14 @@ export default function InteractiveSpeaking({
|
||||
}, [isRecording]);
|
||||
|
||||
useEffect(() => {
|
||||
if (promptIndex === answers.length - 1) {
|
||||
setMediaBlob(answers[promptIndex].blob);
|
||||
if (questionIndex === answers.length - 1) {
|
||||
setMediaBlob(answers[questionIndex].blob);
|
||||
}
|
||||
}, [answers, promptIndex]);
|
||||
}, [answers, questionIndex]);
|
||||
|
||||
const saveAnswer = () => {
|
||||
const answer = {
|
||||
prompt: prompts[promptIndex].text,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: mediaBlob!,
|
||||
};
|
||||
|
||||
@@ -82,8 +83,8 @@ export default function InteractiveSpeaking({
|
||||
</div>
|
||||
{prompts && prompts.length > 0 && (
|
||||
<div className="flex flex-col gap-4 w-full items-center">
|
||||
<video key={promptIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[promptIndex].video_url} />
|
||||
<video key={questionIndex} autoPlay controls className="max-w-3xl rounded-xl">
|
||||
<source src={prompts[questionIndex].video_url} />
|
||||
</video>
|
||||
</div>
|
||||
)}
|
||||
@@ -91,7 +92,7 @@ export default function InteractiveSpeaking({
|
||||
|
||||
<ReactMediaRecorder
|
||||
audio
|
||||
key={promptIndex}
|
||||
key={questionIndex}
|
||||
onStop={(blob) => setMediaBlob(blob)}
|
||||
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">
|
||||
@@ -227,8 +228,8 @@ export default function InteractiveSpeaking({
|
||||
disabled={!mediaBlob}
|
||||
onClick={() => {
|
||||
saveAnswer();
|
||||
if (promptIndex + 1 < prompts.length) {
|
||||
setPromptIndex((prev) => prev + 1);
|
||||
if (questionIndex + 1 < prompts.length) {
|
||||
setQuestionIndex(questionIndex + 1);
|
||||
return;
|
||||
}
|
||||
onNext({
|
||||
@@ -236,7 +237,7 @@ export default function InteractiveSpeaking({
|
||||
solutions: [
|
||||
...answers,
|
||||
{
|
||||
prompt: prompts[promptIndex].text,
|
||||
prompt: prompts[questionIndex].text,
|
||||
blob: mediaBlob!,
|
||||
},
|
||||
],
|
||||
@@ -245,7 +246,7 @@ export default function InteractiveSpeaking({
|
||||
});
|
||||
}}
|
||||
className="max-w-[200px] self-end w-full">
|
||||
{promptIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,28 +5,58 @@ import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill}
|
||||
import dynamic from "next/dynamic";
|
||||
import Button from "../Low/Button";
|
||||
import useExamStore from "@/stores/examStore";
|
||||
import {downloadBlob} from "@/utils/evaluation";
|
||||
import axios from "axios";
|
||||
|
||||
const Waveform = dynamic(() => import("../Waveform"), {ssr: false});
|
||||
const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), {
|
||||
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 [isRecording, setIsRecording] = useState(false);
|
||||
const [mediaBlob, setMediaBlob] = useState<string>();
|
||||
const [audioURL, setAudioURL] = useState<string>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasExamEnded) {
|
||||
onNext({
|
||||
exercise: id,
|
||||
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
|
||||
score: {correct: 1, total: 1, missing: 0},
|
||||
type,
|
||||
});
|
||||
const saveToStorage = async () => {
|
||||
if (mediaBlob && mediaBlob.startsWith("blob")) {
|
||||
const blobBuffer = await downloadBlob(mediaBlob);
|
||||
const audioFile = new File([blobBuffer], "audio.wav", {type: "audio/wav"});
|
||||
|
||||
const seed = Math.random().toString().replace("0.", "");
|
||||
|
||||
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
|
||||
}, [hasExamEnded]);
|
||||
|
||||
@@ -43,6 +73,32 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
};
|
||||
}, [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 (
|
||||
<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">
|
||||
@@ -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">
|
||||
<p className="text-base font-normal">Record your answer:</p>
|
||||
<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" />
|
||||
{status === "idle" && (
|
||||
@@ -168,9 +224,9 @@ export default function Speaking({id, title, text, video_url, type, prompts, onN
|
||||
</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">
|
||||
<BsTrashFill
|
||||
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">
|
||||
<Button
|
||||
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">
|
||||
<Button color="purple" variant="outline" isLoading={isLoading} onClick={back} className="max-w-[200px] self-end w-full">
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
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">
|
||||
<Button color="purple" isLoading={isLoading} disabled={!mediaBlob} onClick={next} className="max-w-[200px] self-end w-full">
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -80,14 +80,14 @@ export default function Speaking({exam, showSolutions = false, onFinish}: Props)
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col h-full w-full gap-8 items-center">
|
||||
<ModuleTitle
|
||||
{/* <ModuleTitle
|
||||
label={convertCamelCaseToReadable(exam.exercises[exerciseIndex].type)}
|
||||
minTimer={exam.minTimer}
|
||||
exerciseIndex={exerciseIndex + 1 + questionIndex + currentQuestionIndex}
|
||||
module="speaking"
|
||||
totalExercises={countExercises(exam.exercises)}
|
||||
disableTimer={showSolutions}
|
||||
/>
|
||||
/> */}
|
||||
{exerciseIndex > -1 &&
|
||||
exerciseIndex < exam.exercises.length &&
|
||||
!showSolutions &&
|
||||
|
||||
@@ -98,13 +98,22 @@ export default function ExamPage({page}: Props) {
|
||||
}, [exams, setUserSolutions, userSolutions]);
|
||||
|
||||
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();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assignment, exam, exams, moduleIndex, selectedModules, sessionId, userSolutions, user, exerciseIndex, partIndex, questionIndex]);
|
||||
|
||||
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
|
||||
}, [timeSpent]);
|
||||
|
||||
@@ -282,7 +291,6 @@ export default function ExamPage({page}: Props) {
|
||||
setUserSolutions([...userSolutions.filter((x) => !solutionIds.includes(x.exercise)), ...solutions]);
|
||||
setModuleIndex(moduleIndex + 1);
|
||||
|
||||
// TODO: Solve the issue for the listening where it should start with -1
|
||||
setPartIndex(-1);
|
||||
setExerciseIndex(-1);
|
||||
setQuestionIndex(0);
|
||||
|
||||
@@ -4,6 +4,7 @@ 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";
|
||||
|
||||
const db = getFirestore(app);
|
||||
|
||||
|
||||
28
src/pages/api/storage/delete.ts
Normal file
28
src/pages/api/storage/delete.ts
Normal 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});
|
||||
}
|
||||
46
src/pages/api/storage/insert.ts
Normal file
46
src/pages/api/storage/insert.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
@@ -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"});
|
||||
return Buffer.from(blobResponse.data, "binary");
|
||||
};
|
||||
|
||||
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 audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioFile, "audio.wav");
|
||||
|
||||
const evaluationQuestion =
|
||||
@@ -64,7 +65,7 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
|
||||
|
||||
const config = {
|
||||
headers: {
|
||||
"Content-Type": "audio/mp3",
|
||||
"Content-Type": "audio/wav",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user