From 121ac8ba4d76cc9ac7a1b92d4c2fa62954b9a935 Mon Sep 17 00:00:00 2001 From: Tiago Ribeiro Date: Fri, 14 Jul 2023 14:15:07 +0100 Subject: [PATCH] Finallyyyyyy finished the whole Speaking flow along with the solution page --- src/components/Exercises/Speaking.tsx | 26 --------- src/components/Solutions/Speaking.tsx | 84 +++++++++++++++++++++++++++ src/components/Solutions/index.tsx | 13 ++++- src/pages/_app.tsx | 2 +- src/pages/api/evaluate/speaking.ts | 35 ++++++----- src/pages/api/speaking.ts | 18 +++--- src/pages/exam.tsx | 11 +--- src/pages/exercises.tsx | 73 ++++++++++++++++++----- src/pages/record.tsx | 2 +- src/utils/score.ts | 18 ++++++ 10 files changed, 206 insertions(+), 76 deletions(-) create mode 100644 src/components/Solutions/Speaking.tsx diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index cc7b2fdd..746f1d18 100644 --- a/src/components/Exercises/Speaking.tsx +++ b/src/components/Exercises/Speaking.tsx @@ -4,7 +4,6 @@ import {Fragment, useEffect, useState} from "react"; import {BsCheckCircleFill, BsMicFill, BsPauseCircle, BsPlayCircle, BsTrashFill} from "react-icons/bs"; import dynamic from "next/dynamic"; import Button from "../Low/Button"; -import axios from "axios"; const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mod) => mod.ReactMediaRecorder), { @@ -29,31 +28,6 @@ export default function Speaking({id, title, text, type, prompts, onNext, onBack }; }, [isRecording]); - useEffect(() => { - const uploadFile = () => { - if (mediaBlob) { - axios.get(mediaBlob, {responseType: "arraybuffer"}).then((response) => { - const audioBlob = Buffer.from(response.data, "binary"); - const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"}); - - const formData = new FormData(); - formData.append("audio", audioFile, "audio.wav"); - formData.append("question", `${text.replaceAll("\n", "")} You should talk about: ${prompts.join(", ")}`); - - const config = { - headers: { - "Content-Type": "audio/mp3", - }, - }; - - axios.post("/api/evaluate/speaking", formData, config); - }); - } - }; - - if (mediaBlob) uploadFile(); - }, [mediaBlob, text, prompts]); - return (
diff --git a/src/components/Solutions/Speaking.tsx b/src/components/Solutions/Speaking.tsx new file mode 100644 index 00000000..999e04bf --- /dev/null +++ b/src/components/Solutions/Speaking.tsx @@ -0,0 +1,84 @@ +/* eslint-disable @next/next/no-img-element */ +import {SpeakingExercise} from "@/interfaces/exam"; +import {CommonProps} from "."; +import {Fragment, useEffect, useState} from "react"; +import Button from "../Low/Button"; +import dynamic from "next/dynamic"; +import axios from "axios"; + +const Waveform = dynamic(() => import("../Waveform"), {ssr: false}); + +export default function Speaking({title, text, prompts, userSolutions, onNext, onBack}: SpeakingExercise & CommonProps) { + const [solutionURL, setSolutionURL] = useState(); + + useEffect(() => { + axios.post(`/api/speaking`, {path: userSolutions[0].solution}, {responseType: "arraybuffer"}).then(({data}) => { + const blob = new Blob([data], {type: "audio/wav"}); + const url = URL.createObjectURL(blob); + + setSolutionURL(url); + }); + }, [userSolutions]); + + return ( + <> +
+
+
+ {title} + + {text.split("\\n").map((line, index) => ( + + {line} +
+
+ ))} +
+
+
+ You should talk about the following things: +
+ {prompts.map((x, index) => ( +
  • + {x} +
  • + ))} +
    +
    +
    + +
    +
    +
    + {solutionURL && } +
    +
    + {userSolutions && userSolutions.length > 0 && ( +
    +
    + {Object.keys(userSolutions[0].evaluation!.task_response).map((key) => ( +
    + {key}: Level {userSolutions[0].evaluation!.task_response[key]} +
    + ))} +
    +
    + {userSolutions[0].evaluation!.comment} +
    +
    + )} +
    +
    + +
    + + + +
    + + ); +} diff --git a/src/components/Solutions/index.tsx b/src/components/Solutions/index.tsx index fdb3d2f3..f77e41f0 100644 --- a/src/components/Solutions/index.tsx +++ b/src/components/Solutions/index.tsx @@ -1,7 +1,16 @@ -import {Exercise, FillBlanksExercise, MatchSentencesExercise, MultipleChoiceExercise, WriteBlanksExercise, WritingExercise} from "@/interfaces/exam"; +import { + Exercise, + FillBlanksExercise, + MatchSentencesExercise, + MultipleChoiceExercise, + SpeakingExercise, + WriteBlanksExercise, + WritingExercise, +} from "@/interfaces/exam"; import dynamic from "next/dynamic"; import FillBlanks from "./FillBlanks"; import MultipleChoice from "./MultipleChoice"; +import Speaking from "./Speaking"; import WriteBlanks from "./WriteBlanks"; import Writing from "./Writing"; @@ -24,5 +33,7 @@ export const renderSolution = (exercise: Exercise, onNext: () => void, onBack: ( return ; case "writing": return ; + case "speaking": + return ; } }; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index d9122cc7..43dc219d 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -15,7 +15,7 @@ export default function App({Component, pageProps}: AppProps) { const router = useRouter(); useEffect(() => { - reset(); + if (router.pathname !== "/exercises") reset(); }, [router.pathname, reset]); return ; diff --git a/src/pages/api/evaluate/speaking.ts b/src/pages/api/evaluate/speaking.ts index beef374a..1241aae9 100644 --- a/src/pages/api/evaluate/speaking.ts +++ b/src/pages/api/evaluate/speaking.ts @@ -20,26 +20,25 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { const storage = getStorage(app); const form = formidable({keepExtensions: true, uploadDir: "./"}); - form.parse(req, (err, fields, files) => { - const audioFile = (files.audio as unknown as PersistentFile[])[0]; - const audioFileRef = ref(storage, `speaking_recordings/${(audioFile as any).newFilename}`); + const [fields, files] = await form.parse(req); + const audioFile = (files.audio as unknown as PersistentFile[])[0]; + const audioFileRef = ref(storage, `speaking_recordings/${(audioFile as any).newFilename}`); - const binary = fs.readFileSync((audioFile as any).filepath).buffer; - uploadBytes(audioFileRef, binary).then(async (snapshot) => { - const backendRequest = await axios.post( - `${process.env.BACKEND_URL}/speaking_task_1`, - {question: (fields.question as string[]).join(""), answer: snapshot.metadata.fullPath}, - { - headers: { - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, - }, - ); + const binary = fs.readFileSync((audioFile as any).filepath).buffer; + const snapshot = await uploadBytes(audioFileRef, binary); - fs.rmSync((audioFile as any).filepath); - res.status(200).json({...backendRequest.data, fullPath: snapshot.metadata.fullPath}); - }); - }); + const backendRequest = await axios.post( + `${process.env.BACKEND_URL}/speaking_task_1`, + {question: (fields.question as string[]).join(""), answer: snapshot.metadata.fullPath}, + { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + }, + ); + + fs.rmSync((audioFile as any).filepath); + res.status(200).json({...backendRequest.data, fullPath: snapshot.metadata.fullPath}); } export const config = { diff --git a/src/pages/api/speaking.ts b/src/pages/api/speaking.ts index 5678693c..d33e927f 100644 --- a/src/pages/api/speaking.ts +++ b/src/pages/api/speaking.ts @@ -4,19 +4,23 @@ import {withIronSessionApiRoute} from "iron-session/next"; import {sessionOptions} from "@/lib/session"; import {getDownloadURL, getStorage, ref} from "firebase/storage"; import {app} from "@/firebase"; +import axios from "axios"; -// export default withIronSessionApiRoute(handler, sessionOptions); -export default handler; +export default withIronSessionApiRoute(handler, sessionOptions); async function handler(req: NextApiRequest, res: NextApiResponse) { - // if (!req.session.user) { - // res.status(401).json({ok: false}); - // return; - // } + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } const storage = getStorage(app); const {path} = req.body as {path: string}; const pathReference = ref(storage, path); - getDownloadURL(pathReference).then((url) => res.status(200).json({url})); + const url = await getDownloadURL(pathReference); + + const response = await axios.get(url, {responseType: "arraybuffer"}); + + res.status(200).send(response.data); } diff --git a/src/pages/exam.tsx b/src/pages/exam.tsx index b7b5e5e7..0713b1db 100644 --- a/src/pages/exam.tsx +++ b/src/pages/exam.tsx @@ -133,7 +133,7 @@ export default function Page() { const speakingExam = exams.find((x) => x.id === examId)!; const exercise = speakingExam.exercises.find((x) => x.id === exerciseId)! as SpeakingExercise; - const blobResponse = await axios.get(exercise.userSolutions[0].solution.trim()); + const blobResponse = await axios.get(solution.solutions[0].solution.trim(), {responseType: "arraybuffer"}); const audioBlob = Buffer.from(blobResponse.data, "binary"); const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"}); @@ -155,7 +155,7 @@ export default function Page() { { ...solution, score: { - correct: speakingReverseMarking[response.data.overall], + correct: speakingReverseMarking[response.data.overall] || 0, missing: 0, total: 100, }, @@ -180,7 +180,7 @@ export default function Page() { { ...solution, score: { - correct: writingReverseMarking[response.data.overall], + correct: writingReverseMarking[response.data.overall] || 0, missing: 0, total: 100, }, @@ -294,11 +294,6 @@ export default function Page() { return ; } - if (exam && exam.module === "speaking" && showSolutions) { - setModuleIndex((prev) => prev + 1); - return <>; - } - if (exam && exam.module === "speaking") { return ; } diff --git a/src/pages/exercises.tsx b/src/pages/exercises.tsx index 1f3a008b..967c8d4d 100644 --- a/src/pages/exercises.tsx +++ b/src/pages/exercises.tsx @@ -6,7 +6,17 @@ import {Module} from "@/interfaces"; import Selection from "@/exams/Selection"; import Reading from "@/exams/Reading"; -import {Exam, ListeningExam, ReadingExam, SpeakingExam, UserSolution, Evaluation, WritingExam, WritingExercise} from "@/interfaces/exam"; +import { + Exam, + ListeningExam, + ReadingExam, + SpeakingExam, + UserSolution, + Evaluation, + WritingExam, + WritingExercise, + SpeakingExercise, +} from "@/interfaces/exam"; import Listening from "@/exams/Listening"; import Writing from "@/exams/Writing"; import {ToastContainer, toast} from "react-toastify"; @@ -22,7 +32,7 @@ import useExamStore from "@/stores/examStore"; import Sidebar from "@/components/Sidebar"; import Layout from "@/components/High/Layout"; import {sortByModule} from "@/utils/moduleUtils"; -import {writingReverseMarking} from "@/utils/score"; +import {speakingReverseMarking, writingReverseMarking} from "@/utils/score"; export const getServerSideProps = withIronSessionSsr(({req, res}) => { const user = req.session.user; @@ -122,6 +132,42 @@ export default function Page() { } }; + const evaluateSpeakingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => { + const speakingExam = exams.find((x) => x.id === examId)!; + const exercise = speakingExam.exercises.find((x) => x.id === exerciseId)! as SpeakingExercise; + + const blobResponse = await axios.get(solution.solutions[0].solution.trim(), {responseType: "arraybuffer"}); + const audioBlob = Buffer.from(blobResponse.data, "binary"); + const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"}); + + const formData = new FormData(); + formData.append("audio", audioFile, "audio.wav"); + formData.append("question", `${exercise.text.replaceAll("\n", "")} You should talk about: ${exercise.prompts.join(", ")}`); + + const config = { + headers: { + "Content-Type": "audio/mp3", + }, + }; + + const response = await axios.post("/api/evaluate/speaking", formData, config); + + if (response.status === 200) { + setUserSolutions([ + ...userSolutions.filter((x) => x.exercise !== exerciseId), + { + ...solution, + score: { + correct: speakingReverseMarking[response.data.overall] || 0, + missing: 0, + total: 100, + }, + solutions: [{id: exerciseId, solution: response.data.fullPath, evaluation: response.data}], + }, + ]); + } + }; + const evaluateWritingAnswer = async (examId: string, exerciseId: string, solution: UserSolution) => { const writingExam = exams.find((x) => x.id === examId)!; const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise; @@ -137,7 +183,7 @@ export default function Page() { { ...solution, score: { - correct: writingReverseMarking[response.data.overall], + correct: writingReverseMarking[response.data.overall] || 0, missing: 0, total: 100, }, @@ -148,9 +194,7 @@ export default function Page() { }; const updateExamWithUserSolutions = (exam: Exam): Exam => { - const exercises = exam.exercises.map((x) => - Object.assign(x, !x.userSolutions ? {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions} : x.userSolutions), - ); + const exercises = exam.exercises.map((x) => Object.assign(x, {userSolutions: userSolutions.find((y) => x.id === y.exercise)?.solutions})); return Object.assign(exam, exercises); }; @@ -158,11 +202,17 @@ export default function Page() { const onFinish = (solutions: UserSolution[]) => { const solutionIds = solutions.map((x) => x.exercise); - if (exam && exam.module === "writing" && solutions.length > 0 && !showSolutions) { + if (exam && (exam.module === "writing" || exam.module === "speaking") && solutions.length > 0 && !showSolutions) { setHasBeenUploaded(true); setIsEvaluationLoading(true); Promise.all( - exam.exercises.map((exercise) => evaluateWritingAnswer(exam.id, exercise.id, solutions.find((x) => x.exercise === exercise.id)!)), + exam.exercises.map((exercise) => + (exam.module === "writing" ? evaluateWritingAnswer : evaluateSpeakingAnswer)( + exam.id, + exercise.id, + solutions.find((x) => x.exercise === exercise.id)!, + ), + ), ).finally(() => { setIsEvaluationLoading(false); setHasBeenUploaded(false); @@ -245,11 +295,6 @@ export default function Page() { return ; } - if (exam && exam.module === "speaking" && showSolutions) { - setModuleIndex((prev) => prev + 1); - return <>; - } - if (exam && exam.module === "speaking") { return ; } @@ -260,7 +305,7 @@ export default function Page() { return ( <> - Exam | IELTS GPT + Exercises | IELTS GPT x!.module), ); - router.push("/exam"); + router.push("/exercises"); } }); }; diff --git a/src/utils/score.ts b/src/utils/score.ts index 2c9bb5da..d1276618 100644 --- a/src/utils/score.ts +++ b/src/utils/score.ts @@ -4,27 +4,45 @@ type Type = "academic" | "general"; export const writingReverseMarking: {[key: number]: number} = { 9: 90, + 8.5: 85, 8: 80, + 7.5: 75, 7: 70, + 6.5: 65, 6: 60, + 5.5: 55, 5: 50, + 4.5: 45, 4: 40, + 3.5: 35, 3: 30, + 2.5: 25, 2: 20, + 1.5: 15, 1: 10, + 0.5: 5, 0: 0, }; export const speakingReverseMarking: {[key: number]: number} = { 9: 90, + 8.5: 85, 8: 80, + 7.5: 75, 7: 70, + 6.5: 65, 6: 60, + 5.5: 55, 5: 50, + 4.5: 45, 4: 40, + 3.5: 35, 3: 30, + 2.5: 25, 2: 20, + 1.5: 15, 1: 10, + 0.5: 5, 0: 0, };