Updated the InteractiveSpeaking to also work with the session persistence

This commit is contained in:
Tiago Ribeiro
2024-02-08 11:43:01 +00:00
parent 2a9e204041
commit b09fe79cb7
12 changed files with 166 additions and 68 deletions

View File

@@ -5,6 +5,8 @@ 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), {
@@ -14,9 +16,11 @@ const ReactMediaRecorder = dynamic(() => import("react-media-recorder").then((mo
export default function InteractiveSpeaking({
id,
title,
examID,
text,
type,
prompts,
userSolutions,
updateIndex,
onNext,
onBack,
@@ -24,21 +28,109 @@ export default function InteractiveSpeaking({
const [recordingDuration, setRecordingDuration] = useState(0);
const [isRecording, setIsRecording] = useState(false);
const [mediaBlob, setMediaBlob] = useState<string>();
const [answers, setAnswers] = useState<{prompt: string; blob: string}[]>([]);
const [answers, setAnswers] = useState<{prompt: string; blob: string; questionIndex: number}[]>([]);
const [isLoading, setIsLoading] = useState(false);
const {questionIndex, setQuestionIndex} = useExamStore((state) => state);
const {userSolutions: storeUserSolutions, setUserSolutions} = useExamStore((state) => state);
const hasExamEnded = useExamStore((state) => state.hasExamEnded);
const saveToStorage = async (previousURL?: string) => {
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 (previousURL && !previousURL.startsWith("blob")) await axios.post("/api/storage/delete", {path: previousURL});
return response.data.path;
}
return undefined;
};
const back = async () => {
setIsLoading(true);
const answer = await saveAnswer(questionIndex);
if (questionIndex - 1 >= 0) {
setQuestionIndex(questionIndex - 1);
setIsLoading(false);
return;
}
setIsLoading(false);
onBack({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
const next = async () => {
setIsLoading(true);
const answer = await saveAnswer(questionIndex);
if (questionIndex + 1 < prompts.length) {
setQuestionIndex(questionIndex + 1);
setIsLoading(false);
return;
}
setIsLoading(false);
onNext({
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
type,
});
};
useEffect(() => {
if (userSolutions.length > 0 && answers.length === 0) {
console.log(userSolutions);
const solutions = userSolutions as unknown as typeof answers;
setAnswers(solutions);
if (!mediaBlob) setMediaBlob(solutions.find((x) => x.questionIndex === questionIndex)?.blob);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [userSolutions, mediaBlob, answers]);
useEffect(() => {
console.log({answers});
}, [answers]);
useEffect(() => {
if (updateIndex) updateIndex(questionIndex);
}, [questionIndex, updateIndex]);
useEffect(() => {
if (hasExamEnded) {
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
};
onNext({
exercise: id,
solutions: mediaBlob ? [{id, solution: mediaBlob}] : [],
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
type,
});
@@ -60,19 +152,38 @@ export default function InteractiveSpeaking({
}, [isRecording]);
useEffect(() => {
if (questionIndex === answers.length - 1) {
setMediaBlob(answers[questionIndex].blob);
if (questionIndex <= answers.length - 1) {
const blob = answers.find((x) => x.questionIndex === questionIndex)?.blob;
setMediaBlob(blob);
}
}, [answers, questionIndex]);
const saveAnswer = () => {
const saveAnswer = async (index: number) => {
const previousURL = answers.find((x) => x.questionIndex === questionIndex)?.blob;
const audioPath = await saveToStorage(previousURL);
const answer = {
questionIndex,
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
blob: audioPath ? audioPath : mediaBlob!,
};
setAnswers((prev) => [...prev, answer]);
setAnswers((prev) => [...prev.filter((x) => x.questionIndex !== index), answer]);
setMediaBlob(undefined);
setUserSolutions([
...storeUserSolutions.filter((x) => x.exercise !== id),
{
exercise: id,
solutions: [...answers.filter((x) => x.questionIndex !== questionIndex), answer],
score: {correct: 1, total: 1, missing: 0},
module: "speaking",
exam: examID,
type,
},
]);
return answer;
};
return (
@@ -98,7 +209,7 @@ export default function InteractiveSpeaking({
<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" && (
@@ -177,9 +288,9 @@ export default function InteractiveSpeaking({
</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"
@@ -209,43 +320,10 @@ export default function InteractiveSpeaking({
/>
<div className="self-end flex justify-between w-full gap-8">
<Button
color="purple"
variant="outline"
onClick={() =>
onBack({
exercise: id,
solutions: answers,
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={() => {
saveAnswer();
if (questionIndex + 1 < prompts.length) {
setQuestionIndex(questionIndex + 1);
return;
}
onNext({
exercise: id,
solutions: [
...answers,
{
prompt: prompts[questionIndex].text,
blob: mediaBlob!,
},
],
score: {correct: 1, total: 1, missing: 0},
type,
});
}}
className="max-w-[200px] self-end w-full">
<Button color="purple" disabled={!mediaBlob} isLoading={isLoading} onClick={next} className="max-w-[200px] self-end w-full">
{questionIndex + 1 < prompts.length ? "Next Prompt" : "Submit"}
</Button>
</div>

View File

@@ -22,6 +22,7 @@ import InteractiveSpeaking from "./InteractiveSpeaking";
const MatchSentences = dynamic(() => import("@/components/Exercises/MatchSentences"), {ssr: false});
export interface CommonProps {
examID?: string;
updateIndex?: (internalIndex: number) => void;
onNext: (userSolutions: UserSolution) => void;
onBack: (userSolutions: UserSolution) => void;
@@ -29,17 +30,18 @@ export interface CommonProps {
export const renderExercise = (
exercise: Exercise,
examID: string,
onNext: (userSolutions: UserSolution) => void,
onBack: (userSolutions: UserSolution) => void,
updateIndex?: (internalIndex: number) => void,
) => {
switch (exercise.type) {
case "fillBlanks":
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <FillBlanks key={exercise.id} {...(exercise as FillBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "trueFalse":
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} />;
return <TrueFalse key={exercise.id} {...(exercise as TrueFalseExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "matchSentences":
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} />;
return <MatchSentences key={exercise.id} {...(exercise as MatchSentencesExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "multipleChoice":
return (
<MultipleChoice
@@ -48,19 +50,21 @@ export const renderExercise = (
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}
examID={examID}
/>
);
case "writeBlanks":
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} />;
return <WriteBlanks key={exercise.id} {...(exercise as WriteBlanksExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "writing":
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} />;
return <Writing key={exercise.id} {...(exercise as WritingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "speaking":
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} />;
return <Speaking key={exercise.id} {...(exercise as SpeakingExercise)} onNext={onNext} onBack={onBack} examID={examID} />;
case "interactiveSpeaking":
return (
<InteractiveSpeaking
key={exercise.id}
{...(exercise as InteractiveSpeakingExercise)}
examID={examID}
updateIndex={updateIndex}
onNext={onNext}
onBack={onBack}

View File

@@ -91,7 +91,7 @@ export default function Level({exam, showSolutions = false, onFinish}: Props) {
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -183,7 +183,7 @@ export default function Listening({exam, showSolutions = false, onFinish}: Props
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{/* Solution renderer */}
{exerciseIndex > -1 &&

View File

@@ -243,7 +243,7 @@ export default function Reading({exam, showSolutions = false, onFinish}: Props)
partIndex > -1 &&
exerciseIndex < exam.parts[partIndex].exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
partIndex > -1 &&

View File

@@ -80,18 +80,18 @@ 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 &&
renderExercise(getExercise(), nextExercise, previousExercise, setCurrentQuestionIndex)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise, setCurrentQuestionIndex)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -81,7 +81,7 @@ export default function Writing({exam, showSolutions = false, onFinish}: Props)
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
!showSolutions &&
renderExercise(getExercise(), nextExercise, previousExercise)}
renderExercise(getExercise(), exam.id, nextExercise, previousExercise)}
{exerciseIndex > -1 &&
exerciseIndex < exam.exercises.length &&
showSolutions &&

View File

@@ -164,7 +164,7 @@ export interface InteractiveSpeakingExercise {
prompts: {text: string; video_url: string}[];
userSolutions: {
id: string;
solution: {question: string; answer: string}[];
solution: {questionIndex: number; question: string; answer: string}[];
evaluation?: InteractiveSpeakingEvaluation;
}[];
}

View File

@@ -181,8 +181,8 @@ export default function ExamPage({page}: Props) {
id: solution.id || uuidv4(),
timeSpent,
session: sessionId,
exam: solution.exam!,
module: solution.module!,
exam: exam!.id,
module: exam!.module,
user: user?.id || "",
date: new Date().getTime(),
...(assignment ? {assignment: assignment.id} : {}),
@@ -328,6 +328,8 @@ export default function ExamPage({page}: Props) {
};
answers.forEach((x) => {
console.log({x});
scores[x.module!] = {
total: scores[x.module!].total + x.score.total,
correct: scores[x.module!].correct + x.score.correct,
@@ -366,6 +368,8 @@ export default function ExamPage({page}: Props) {
onViewResults={() => {
setShowSolutions(true);
setModuleIndex(0);
setExerciseIndex(["reading", "listening"].includes(exams[0].module) ? -1 : 0);
setPartIndex(exams[0].module === "listening" ? -1 : 0);
setExam(exams[0]);
}}
scores={aggregateScoresByModule(userSolutions)}

View File

@@ -28,7 +28,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
}
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;

View File

@@ -178,7 +178,10 @@ export default function History({user}: {user: User}) {
const {timeSpent, session} = dateStats[0];
const selectExam = () => {
const examPromises = uniqBy(dateStats, "exam").map((stat) => getExamById(stat.module, stat.exam));
const examPromises = uniqBy(dateStats, "exam").map((stat) => {
console.log({stat});
return getExamById(stat.module, stat.exam);
});
Promise.all(examPromises).then((exams) => {
if (exams.every((x) => !!x)) {

View File

@@ -53,9 +53,12 @@ export const downloadBlob = async (url: string): Promise<Buffer> => {
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 url = solution.solutions[0].solution.trim() as string;
const audioBlob = await downloadBlob(url);
const audioFile = new File([audioBlob], "audio.wav", {type: "audio/wav"});
if (url && !url.startsWith("blob")) await axios.post("/api/storage/delete", {path: url});
formData.append("audio", audioFile, "audio.wav");
const evaluationQuestion =
@@ -87,10 +90,15 @@ const evaluateSpeakingExercise = async (exercise: SpeakingExercise, exerciseId:
};
const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution: UserSolution, id: string) => {
const promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => ({
const promiseParts = solution.solutions.map(async (x: {prompt: string; blob: string}) => {
const blob = await downloadBlob(x.blob);
if (!x.blob.startsWith("blob")) await axios.post("/api/storage/delete", {path: x.blob});
return {
question: x.prompt,
answer: await downloadBlob(x.blob),
}));
answer: blob,
};
});
const body = await Promise.all(promiseParts);
const formData = new FormData();
@@ -111,6 +119,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
};
const response = await axios.post("/api/evaluate/interactiveSpeaking", formData, config);
console.log({data: response.data, status: response.status});
if (response.status === 200) {
return {
@@ -120,6 +129,7 @@ const evaluateInteractiveSpeakingExercise = async (exerciseId: string, solution:
missing: 0,
total: 100,
},
module: "speaking",
solutions: [{id: exerciseId, solution: response.data ? response.data.answer : null, evaluation: response.data}],
};
}