"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
{attempts}
ATTEMPTS
{bestScore}
BEST SCORE
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

{question}

{transcript && (
Live Transcript
{transcript}
)}
{(phase === "idle" || phase === "done") && ( <> )} {phase === "recording" && ( <> )} {phase === "prep" && ( <> )}
{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) => (
{item.label}
{item.value}/{item.max}
))}
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}

)}
); }