diff --git a/package.json b/package.json index ef580cc0..6f504b18 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "eslint": "8.33.0", "eslint-config-next": "13.1.6", "firebase": "9.19.1", + "formidable": "^3.5.0", + "formidable-serverless": "^1.1.1", "framer-motion": "^9.0.2", "iron-session": "^6.3.1", "lodash": "^4.17.21", @@ -48,6 +50,7 @@ "zustand": "^4.3.6" }, "devDependencies": { + "@types/formidable": "^3.4.0", "@types/lodash": "^4.14.191", "@types/uuid": "^9.0.1", "@types/wavesurfer.js": "^6.0.6", diff --git a/src/components/Exercises/Speaking.tsx b/src/components/Exercises/Speaking.tsx index 55b19322..1cbfb7ca 100644 --- a/src/components/Exercises/Speaking.tsx +++ b/src/components/Exercises/Speaking.tsx @@ -1,14 +1,10 @@ -import {errorButtonStyle, infoButtonStyle} from "@/constants/buttonStyles"; -import {SpeakingExercise, WritingExercise} from "@/interfaces/exam"; -import {mdiArrowLeft, mdiArrowRight} from "@mdi/js"; -import Icon from "@mdi/react"; -import clsx from "clsx"; +import {SpeakingExercise} from "@/interfaces/exam"; import {CommonProps} from "."; import {Fragment, useEffect, useState} from "react"; -import {toast} from "react-toastify"; 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), { @@ -33,6 +29,30 @@ 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"); + + const config = { + headers: { + "Content-Type": "audio/mp3", + }, + }; + + axios.post("/api/evaluate/speaking", formData, config); + }); + } + }; + + if (mediaBlob) uploadFile(); + }, [mediaBlob]); + return (
diff --git a/src/pages/api/evaluate/speaking.ts b/src/pages/api/evaluate/speaking.ts new file mode 100644 index 00000000..ce275aba --- /dev/null +++ b/src/pages/api/evaluate/speaking.ts @@ -0,0 +1,46 @@ +// Next.js API route support: https://nextjs.org/docs/api-routes/introduction +import type {NextApiRequest, NextApiResponse} from "next"; +import {getFirestore, doc, getDoc} from "firebase/firestore"; +import {withIronSessionApiRoute} from "iron-session/next"; +import {sessionOptions} from "@/lib/session"; +import axios from "axios"; +import formidable from "formidable"; +import PersistentFile from "formidable/PersistentFile"; +import {getStorage, ref, uploadBytes} from "firebase/storage"; +import fs from "fs"; + +export default withIronSessionApiRoute(handler, sessionOptions); + +async function handler(req: NextApiRequest, res: NextApiResponse) { + if (!req.session.user) { + res.status(401).json({ok: false}); + return; + } + + const storage = getStorage(); + + 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 binary = fs.readFileSync((audioFile as any).filepath).buffer; + uploadBytes(audioFileRef, binary).then(async (snapshot) => { + const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + }); + + fs.rmSync((audioFile as any).filepath); + }); + }); + + res.status(200).json({ok: true}); +} + +export const config = { + api: { + bodyParser: false, + }, +}; diff --git a/src/pages/api/exam/[module]/evaluate.ts b/src/pages/api/evaluate/writing.ts similarity index 60% rename from src/pages/api/exam/[module]/evaluate.ts rename to src/pages/api/evaluate/writing.ts index 3527fe67..bc5812c0 100644 --- a/src/pages/api/exam/[module]/evaluate.ts +++ b/src/pages/api/evaluate/writing.ts @@ -18,19 +18,11 @@ async function handler(req: NextApiRequest, res: NextApiResponse) { return; } - const {module} = req.query as {module: string}; + const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, { + headers: { + Authorization: `Bearer ${process.env.BACKEND_JWT}`, + }, + }); - if (module === "writing") { - const backendRequest = await axios.post(`${process.env.BACKEND_URL}/writing_task2`, req.body as Body, { - headers: { - Authorization: `Bearer ${process.env.BACKEND_JWT}`, - }, - }); - - res.status(backendRequest.status).json(backendRequest.data); - return; - } - - res.status(404).json({ok: false}); - return; + res.status(backendRequest.status).json(backendRequest.data); } diff --git a/src/pages/exam.tsx b/src/pages/exam.tsx index 52822cca..f702939e 100644 --- a/src/pages/exam.tsx +++ b/src/pages/exam.tsx @@ -123,7 +123,7 @@ export default function Page() { const writingExam = exams.find((x) => x.id === examId)!; const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise; - const response = await axios.post("/api/exam/writing/evaluate", { + const response = await axios.post("/api/evaluate/writing", { question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), }); diff --git a/src/pages/exercises.tsx b/src/pages/exercises.tsx index 8bb87b52..d85b69b8 100644 --- a/src/pages/exercises.tsx +++ b/src/pages/exercises.tsx @@ -126,7 +126,7 @@ export default function Page() { const writingExam = exams.find((x) => x.id === examId)!; const exercise = writingExam.exercises.find((x) => x.id === exerciseId)! as WritingExercise; - const response = await axios.post("/api/exam/writing/evaluate", { + const response = await axios.post("/api/evaluate/writing", { question: `${exercise.prompt} ${exercise.attachment ? exercise.attachment.description : ""}`.replaceAll("\n", ""), answer: solution.solutions[0].solution.trim().replaceAll("\n", " "), }); diff --git a/yarn.lock b/yarn.lock index 2ff5c405..7988ba0e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -797,6 +797,13 @@ "@types/qs" "*" "@types/serve-static" "*" +"@types/formidable@^3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@types/formidable/-/formidable-3.4.0.tgz#5a671de8f88f3f313b31041f4746141e4431f82b" + integrity sha512-JXP+LsspYYBIJJxZ9VJsswb5U1hkUUhLmtAb6EB1SWcDDbJQlWRhGoZYasMSnk2NsqtUEHd3uuaiImmSys+8AQ== + dependencies: + "@types/node" "*" + "@types/http-assert@*": version "1.5.3" resolved "https://registry.yarnpkg.com/@types/http-assert/-/http-assert-1.5.3.tgz#ef8e3d1a8d46c387f04ab0f2e8ab8cb0c5078661" @@ -1126,6 +1133,11 @@ array.prototype.tosorted@^1.1.1: es-shim-unscopables "^1.0.0" get-intrinsic "^1.1.3" +asap@^2.0.0: + version "2.0.6" + resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + asn1js@^3.0.1, asn1js@^3.0.5: version "3.0.5" resolved "https://registry.yarnpkg.com/asn1js/-/asn1js-3.0.5.tgz#5ea36820443dbefb51cc7f88a2ebb5b462114f38" @@ -1477,6 +1489,14 @@ dequal@^2.0.3: resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== +dezalgo@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81" + integrity sha512-rXSP0bf+5n0Qonsb+SVVfNfIsimO4HEtmnIpPHY8Q1UCzKlQrDMfdobr8nJOOsRgWCyMRqeSBQzmWUMq7zvVig== + dependencies: + asap "^2.0.0" + wrappy "1" + didyoumean@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" @@ -1994,6 +2014,27 @@ form-data@^4.0.0: combined-stream "^1.0.8" mime-types "^2.1.12" +formidable-serverless@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/formidable-serverless/-/formidable-serverless-1.1.1.tgz#2b668d6e92d5222794a48ed485bcf7cd47efebfb" + integrity sha512-IUVI2m7d46YnWRZ9RG+wvmLoX0tTrk9P6oFc53QVjM9k0XhqzeyjwDi05PLvpGNKbyLvB3gfsAc+Lk6efM3FGQ== + dependencies: + formidable "^1.2.2" + +formidable@^1.2.2: + version "1.2.6" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-1.2.6.tgz#d2a51d60162bbc9b4a055d8457a7c75315d1a168" + integrity sha512-KcpbcpuLNOwrEjnbpMC0gS+X8ciDoZE1kkqzat4a8vrprf+s9pKNQ/QIwWfbfs4ltgmFl3MD177SNTkve3BwGQ== + +formidable@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/formidable/-/formidable-3.5.0.tgz#3605a9325130d05c550d57be8e81d1757baa12d6" + integrity sha512-WwsMWvPmY+Kv37C3+KP3A+2Ym1aZoac4nz4ZEe5z0UPBoCg0O/wHay3eeYkZr4KJIbCzpSUeno+STMhde+KCfw== + dependencies: + dezalgo "^1.0.4" + hexoid "^1.0.0" + once "^1.4.0" + fraction.js@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" @@ -2218,6 +2259,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +hexoid@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/hexoid/-/hexoid-1.0.0.tgz#ad10c6573fb907de23d9ec63a711267d9dc9bc18" + integrity sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g== + http-parser-js@>=0.5.1: version "0.5.8" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" @@ -2823,7 +2869,7 @@ object.values@^1.1.6: define-properties "^1.1.4" es-abstract "^1.20.4" -once@^1.3.0: +once@^1.3.0, once@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==