"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import { PTE_QUESTIONS } from "./questions.js";
function normalizeText(text) {
return text
.toLowerCase()
.replace(/[^a-z\s]/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function calculateScore(spoken, expected) {
const spokenWords = normalizeText(spoken).split(" ").filter(Boolean);
const expectedWords = normalizeText(expected).split(" ").filter(Boolean);
if (!spokenWords.length || !expectedWords.length) return null;
let matched = 0;
const used = new Set();
const wordDetails = expectedWords.map((word) => {
const idx = spokenWords.findIndex((w, i) => w === word && !used.has(i));
if (idx !== -1) {
used.add(idx);
matched++;
return { word, correct: true };
}
return { word, correct: false };
});
const wordAccuracy = Math.round((matched / expectedWords.length) * 100);
const fluencyRatio = Math.min(spokenWords.length / expectedWords.length, 1.15);
const content = Math.min(6, Math.round((matched / expectedWords.length) * 6));
const fluency = Math.min(5, Math.max(1, Math.round(fluencyRatio * 5)));
const pronunciation = Math.min(5, Math.max(1, Math.round((wordAccuracy / 100) * 5)));
const overall = Math.min(
90,
Math.round((content / 6) * 40 + (fluency / 5) * 25 + (pronunciation / 5) * 25)
);
let feedback = "";
if (overall >= 75) {
feedback =
"Excellent performance. Your fluency and pronunciation are strong. Keep maintaining a natural pace.";
} else if (overall >= 60) {
feedback =
"Good attempt. Focus on clearer pronunciation and covering every word in the passage.";
} else if (overall >= 40) {
feedback =
"Fair attempt. Read more slowly, stress important words, and avoid skipping words.";
} else {
feedback =
"You need more practice. First read the passage silently, then speak clearly and confidently.";
}
return {
overall,
content,
fluency,
pronunciation,
wordAccuracy,
matched,
expectedCount: expectedWords.length,
spokenCount: spokenWords.length,
wordDetails,
feedback,
};
}
export default function ReadAloudPage() {
const [current, setCurrent] = useState(0);
const [phase, setPhase] = useState("idle"); // idle | prep | recording | done
const [prepTime, setPrepTime] = useState(35);
const [recordTime, setRecordTime] = useState(40);
const [transcript, setTranscript] = useState("");
const [analysis, setAnalysis] = useState(null);
const [error, setError] = useState("");
const [bestScore, setBestScore] = useState(0);
const [attempts, setAttempts] = useState(0);
const recognitionRef = useRef(null);
const prepTimerRef = useRef(null);
const recordTimerRef = useRef(null);
const audioCtxRef = useRef(null);
const transcriptRef = useRef("");
const totalQuestions = PTE_QUESTIONS.length;
const question = PTE_QUESTIONS[current];
const wordCount = question.split(/\s+/).filter(Boolean).length;
const progress = ((current + 1) / totalQuestions) * 100;
const playBeep = useCallback((type) => {
try {
if (!audioCtxRef.current) {
audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)();
}
const ctx = audioCtxRef.current;
const makeTone = (freq, duration, delay = 0) => {
setTimeout(() => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.connect(gain);
gain.connect(ctx.destination);
osc.type = "sine";
osc.frequency.value = freq;
gain.gain.value = 0.22;
osc.start();
gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
osc.stop(ctx.currentTime + duration);
}, delay);
};
if (type === "start") {
makeTone(520, 0.12, 0);
makeTone(660, 0.12, 140);
makeTone(780, 0.16, 280);
} else if (type === "stop") {
makeTone(780, 0.14, 0);
makeTone(520, 0.22, 180);
} else if (type === "tick") {
makeTone(1000, 0.08, 0);
}
} catch (e) {}
}, []);
const cleanup = useCallback(() => {
clearInterval(prepTimerRef.current);
clearInterval(recordTimerRef.current);
try {
if (recognitionRef.current) recognitionRef.current.stop();
} catch (e) {}
}, []);
useEffect(() => {
return () => cleanup();
}, [cleanup]);
const resetQuestion = useCallback(() => {
cleanup();
setPhase("idle");
setPrepTime(35);
setRecordTime(40);
setTranscript("");
setAnalysis(null);
setError("");
transcriptRef.current = "";
}, [cleanup]);
const finishRecording = useCallback(() => {
clearInterval(recordTimerRef.current);
try {
if (recognitionRef.current) recognitionRef.current.stop();
} catch (e) {}
}, []);
const startRecording = useCallback(() => {
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
if (!SR) {
setError("Please use Google Chrome browser for speech recognition.");
setPhase("idle");
return;
}
transcriptRef.current = "";
setTranscript("");
setAnalysis(null);
setError("");
setPhase("recording");
setRecordTime(40);
playBeep("start");
const recognition = new SR();
recognitionRef.current = recognition;
recognition.lang = "en-US";
recognition.continuous = true;
recognition.interimResults = true;
recognition.onresult = (event) => {
let finalText = "";
let interimText = "";
for (let i = 0; i < event.results.length; i++) {
const chunk = event.results[i][0].transcript;
if (event.results[i].isFinal) {
finalText += chunk + " ";
} else {
interimText += chunk;
}
}
transcriptRef.current = finalText;
setTranscript(finalText + interimText);
};
recognition.onerror = (event) => {
if (event.error === "not-allowed") {
setError("Microphone permission denied. Please allow microphone access and reload.");
setPhase("idle");
}
};
recognition.onend = () => {
playBeep("stop");
const finalSpeech = transcriptRef.current.trim();
if (!finalSpeech) {
setError("No speech detected. Please speak clearly and try again.");
setPhase("done");
return;
}
const result = calculateScore(finalSpeech, question);
if (result) {
setAnalysis(result);
setAttempts((prev) => prev + 1);
setBestScore((prev) => Math.max(prev, result.overall));
} else {
setError("Unable to analyze speech. Please try again.");
}
setPhase("done");
};
try {
recognition.start();
} catch (e) {
setError("Could not start recording. Please refresh and try again.");
setPhase("idle");
return;
}
let t = 40;
recordTimerRef.current = setInterval(() => {
t -= 1;
setRecordTime(t);
if (t <= 0) {
clearInterval(recordTimerRef.current);
finishRecording();
}
}, 1000);
}, [finishRecording, playBeep, question]);
const startPractice = useCallback(() => {
cleanup();
setPhase("prep");
setPrepTime(35);
setRecordTime(40);
setTranscript("");
setAnalysis(null);
setError("");
transcriptRef.current = "";
let t = 35;
prepTimerRef.current = setInterval(() => {
t -= 1;
setPrepTime(t);
if (t <= 3 && t >= 1) playBeep("tick");
if (t <= 0) {
clearInterval(prepTimerRef.current);
startRecording();
}
}, 1000);
}, [cleanup, playBeep, startRecording]);
const skipQuestion = () => {
cleanup();
setCurrent((prev) => (prev < totalQuestions - 1 ? prev + 1 : 0));
setPhase("idle");
setPrepTime(35);
setRecordTime(40);
setTranscript("");
setAnalysis(null);
setError("");
transcriptRef.current = "";
};
const goPrev = () => {
if (current === 0) return;
cleanup();
setCurrent((prev) => prev - 1);
setPhase("idle");
setPrepTime(35);
setRecordTime(40);
setTranscript("");
setAnalysis(null);
setError("");
transcriptRef.current = "";
};
const goNext = () => {
if (current >= totalQuestions - 1) return;
cleanup();
setCurrent((prev) => prev + 1);
setPhase("idle");
setPrepTime(35);
setRecordTime(40);
setTranscript("");
setAnalysis(null);
setError("");
transcriptRef.current = "";
};
return (
E
EVEE PTE CLASSES
OVERSEAS ADMISSIONS HUB
SPEAKING
Read Aloud
|
Item {current + 1} of {totalQuestions}
AI Scoring
{phase === "idle" &&
'Look at the text below. When you are ready, click "Begin". You will have 35 seconds to prepare. Then a tone will sound and you must read the text aloud.'}
{phase === "prep" &&
`Preparation time remaining: ${prepTime} seconds. Read the passage silently and prepare to speak.`}
{phase === "recording" &&
`Recording in progress. Please read the text aloud clearly and naturally. Time remaining: ${recordTime} seconds.`}
{phase === "done" &&
"Recording completed. Review your score report below."}
{phase === "idle" && "READY"}
{phase === "prep" && "PREPARING"}
{phase === "recording" && "RECORDING"}
{phase === "done" && "COMPLETED"}
{(phase === "prep" || phase === "recording") && (
{phase === "prep" ? "Prep Time" : "Record Time"}
{phase === "prep" ? prepTime : recordTime}
s
)}
Reading Passage
{wordCount} words
{transcript && (
)}
{(phase === "idle" || phase === "done") && (
<>
{phase === "done" ? "Try Again" : "Begin"}
Previous
Skip
Next
>
)}
{phase === "recording" && (
<>
Stop Recording
Skip
Cancel
>
)}
{phase === "prep" && (
<>
Skip
Cancel
>
)}
{error && (
{error}
)}
{analysis && (
EVEE PTE CLASSES
Read Aloud Score Report
{current + 1}/{totalQuestions}
{analysis.overall}
out of 90
{[
{ label: "Content", value: analysis.content, max: 6 },
{ label: "Oral Fluency", value: analysis.fluency, max: 5 },
{ label: "Pronunciation", value: analysis.pronunciation, max: 5 },
].map((item) => (
))}
Word Accuracy
{analysis.wordAccuracy}%
{analysis.matched} matched
{analysis.spokenCount} spoken
{analysis.expectedCount} expected
Word Analysis
{analysis.wordDetails.map((w, i) => (
{w.word}
))}
Examiner Feedback
{analysis.feedback}
)}
);
}