2025-05-16 16:27:50 +07:00
// ai-tutor-poc/src/App.jsx
import React , { useState , useRef , useEffect } from 'react' ;
2025-05-12 21:54:32 +07:00
import './App.css' ;
const OPENAI _API _KEY = import . meta . env . VITE _OPENAI _API _KEY ;
function App ( ) {
const [ transcript , setTranscript ] = useState ( '' ) ;
const [ aiReply , setAiReply ] = useState ( '' ) ;
const [ isRecording , setIsRecording ] = useState ( false ) ;
const mediaRecorderRef = useRef ( null ) ;
const audioChunksRef = useRef ( [ ] ) ;
2025-05-16 16:27:50 +07:00
const audioStreamRef = useRef ( null ) ;
const silenceTimerRef = useRef ( null ) ;
const canvasRef = useRef ( null ) ;
const analyserRef = useRef ( null ) ;
const dataArrayRef = useRef ( null ) ;
const audioContextRef = useRef ( null ) ;
2025-05-19 02:03:56 +07:00
const systemContent = ` You are an English tutor who is kind, fun to be around, and skilled at teaching English through engaging stories and conversations. You are assigned to talk with a primary school EFL student to help them learn English.
The conversation will start with the student greeting you in Thai , and you will invite the student to learn English . Explain that today ' s lesson will be based on a conversation about the movie they watched yesterday .
Focus on the following learning objectives :
- Introduce and practice new vocabulary related to the movie .
- Encourage the student to describe the movie plot , characters , and lessons learned .
- Practice forming complete sentences and expressing opinions .
Provide context for the movie by briefly describing it ( e . g . , "Yesterday, you watched an animated movie about a brave young hero who goes on an adventure to save their village." ) .
Use an encouraging and patient tone . Ask open - ended questions to prompt the student to use new vocabulary and phrases . For example :
- Can you tell me about your favorite character in the movie ?
- What was the most exciting part of the movie for you ?
- How do you think the hero felt when they achieved their goal ?
Include interactive elements such as :
- Asking the student to describe a scene from the movie using new vocabulary .
- Encouraging the student to act out a short dialogue from the movie .
The whole conversation should last about 5 minutes , keeping the student engaged and motivated to practice their English skills . ` ;
2025-05-16 16:27:50 +07:00
useEffect ( ( ) => {
const initRecording = async ( ) => {
audioStreamRef . current = await navigator . mediaDevices . getUserMedia ( { audio : true } ) ;
mediaRecorderRef . current = new MediaRecorder ( audioStreamRef . current ) ;
mediaRecorderRef . current . ondataavailable = ( e ) => {
audioChunksRef . current . push ( e . data ) ;
} ;
mediaRecorderRef . current . onstop = async ( ) => {
const inputAudioBlob = new Blob ( audioChunksRef . current , { type : 'audio/webm' } ) ;
audioChunksRef . current = [ ] ; // Clear for next recording
const formData = new FormData ( ) ;
formData . append ( 'file' , inputAudioBlob , 'input.webm' ) ;
formData . append ( 'model' , 'whisper-1' ) ;
const whisperRes = await fetch ( 'https://api.openai.com/v1/audio/transcriptions' , {
method : 'POST' ,
headers : {
Authorization : ` Bearer ${ OPENAI _API _KEY } `
} ,
body : formData
} ) ;
const { text } = await whisperRes . json ( ) ;
setTranscript ( text ) ;
const chatRes = await fetch ( 'https://api.openai.com/v1/chat/completions' , {
method : 'POST' ,
headers : {
'Authorization' : ` Bearer ${ OPENAI _API _KEY } ` ,
'Content-Type' : 'application/json'
} ,
body : JSON . stringify ( {
model : 'gpt-4o' ,
messages : [
2025-05-19 02:03:56 +07:00
{ role : 'system' , content : systemContent } ,
2025-05-16 16:27:50 +07:00
{ role : 'user' , content : text }
]
} )
} ) ;
const chatData = await chatRes . json ( ) ;
if ( ! chatData . choices || ! chatData . choices [ 0 ] ) {
console . error ( 'Chat API response error:' , chatData ) ;
setAiReply ( 'Sorry, something went wrong with the AI response.' ) ;
return ;
}
const reply = chatData . choices [ 0 ] . message . content ;
setAiReply ( reply ) ;
const speechRes = await fetch ( 'https://api.openai.com/v1/audio/speech' , {
method : 'POST' ,
headers : {
'Authorization' : ` Bearer ${ OPENAI _API _KEY } ` ,
'Content-Type' : 'application/json'
} ,
body : JSON . stringify ( {
model : 'tts-1-hd' ,
voice : 'nova' ,
input : reply
} )
} ) ;
const outputAudioBlob = await speechRes . blob ( ) ;
const audioUrl = URL . createObjectURL ( outputAudioBlob ) ;
const audio = new Audio ( audioUrl ) ;
audio . play ( ) ;
audio . onended = ( ) => {
if ( mediaRecorderRef . current && audioStreamRef . current ) {
audioChunksRef . current = [ ] ;
mediaRecorderRef . current . start ( ) ;
setIsRecording ( true ) ;
monitorSilence ( ) ;
}
} ;
} ;
2025-05-12 21:54:32 +07:00
} ;
2025-05-16 16:27:50 +07:00
initRecording ( ) ;
} , [ ] ) ;
const monitorSilence = ( ) => {
if ( ! audioStreamRef . current ) return ;
if ( audioContextRef . current ) {
audioContextRef . current . close ( ) ;
}
audioContextRef . current = new AudioContext ( ) ;
// Resume AudioContext to avoid browser autoplay policy issues
audioContextRef . current . resume ( ) . then ( ( ) => {
const source = audioContextRef . current . createMediaStreamSource ( audioStreamRef . current ) ;
const analyser = audioContextRef . current . createAnalyser ( ) ;
analyser . fftSize = 2048 ;
analyserRef . current = analyser ;
const bufferLength = analyser . frequencyBinCount ;
const dataArray = new Uint8Array ( bufferLength ) ;
dataArrayRef . current = dataArray ;
source . connect ( analyser ) ;
const canvas = canvasRef . current ;
const canvasCtx = canvas . getContext ( '2d' ) ;
const SILENCE _THRESHOLD = 0.02 ; // Adjust as needed (0 to 1 scale)
const SILENCE _TIMEOUT = 1500 ; // ms
let silenceStart = null ;
const checkSilenceAndDraw = ( ) => {
analyser . getByteTimeDomainData ( dataArray ) ;
// Draw waveform (same as before)
canvasCtx . fillStyle = '#000' ;
canvasCtx . fillRect ( 0 , 0 , canvas . width , canvas . height ) ;
canvasCtx . lineWidth = 2 ;
canvasCtx . strokeStyle = '#00ff00' ;
canvasCtx . beginPath ( ) ;
const sliceWidth = canvas . width / bufferLength ;
let x = 0 ;
for ( let i = 0 ; i < bufferLength ; i ++ ) {
const v = dataArray [ i ] / 128.0 ;
const y = v * canvas . height / 2 ;
if ( i === 0 ) {
canvasCtx . moveTo ( x , y ) ;
} else {
canvasCtx . lineTo ( x , y ) ;
}
x += sliceWidth ;
}
canvasCtx . lineTo ( canvas . width , canvas . height / 2 ) ;
canvasCtx . stroke ( ) ;
// RMS calculation
let sumSquares = 0 ;
for ( let i = 0 ; i < bufferLength ; i ++ ) {
const normalized = ( dataArray [ i ] - 128 ) / 128 ;
sumSquares += normalized * normalized ;
}
const rms = Math . sqrt ( sumSquares / bufferLength ) ;
if ( rms < SILENCE _THRESHOLD ) {
// Silence detected
if ( ! silenceStart ) silenceStart = Date . now ( ) ;
else if ( Date . now ( ) - silenceStart > SILENCE _TIMEOUT ) {
if ( mediaRecorderRef . current && isRecording ) {
mediaRecorderRef . current . stop ( ) ;
setIsRecording ( false ) ;
audioContextRef . current . close ( ) ;
}
silenceStart = null ; // reset after stopping
return ; // stop animation loop on silence stop
}
} else {
// Sound detected
silenceStart = null ;
2025-05-12 21:54:32 +07:00
}
2025-05-16 16:27:50 +07:00
requestAnimationFrame ( checkSilenceAndDraw ) ;
2025-05-12 21:54:32 +07:00
} ;
2025-05-16 16:27:50 +07:00
} ) ;
} ;
2025-05-16 15:21:02 +07:00
2025-05-16 16:27:50 +07:00
const toggleRecording = ( ) => {
if ( ! isRecording ) {
audioChunksRef . current = [ ] ;
mediaRecorderRef . current . start ( ) ;
setIsRecording ( true ) ;
monitorSilence ( ) ;
} else {
mediaRecorderRef . current . stop ( ) ;
setIsRecording ( false ) ;
2025-05-16 15:21:02 +07:00
}
2025-05-12 21:54:32 +07:00
} ;
return (
2025-05-19 01:41:58 +07:00
2025-05-16 16:27:50 +07:00
< div className = "app-container" >
2025-05-19 01:41:58 +07:00
< div className = "page-title" >
< strong > AI English Tutor - Yesterday ' s movie < / strong >
< / div >
2025-05-16 16:27:50 +07:00
< div className = "scene-wrapper" >
2025-05-19 01:41:58 +07:00
2025-05-16 16:27:50 +07:00
< img src = "/tutor_f.png" alt = "Tutor Avatar" className = "avatar" / >
< div className = "dialogue-box" >
< div className = "dialogue-text" >
2025-05-19 01:22:20 +07:00
< strong > ค ุ ณ : < / strong > { transcript || < em > ลองพ ู ดอะไรบางอย ่ าง < / em > }
2025-05-16 16:27:50 +07:00
< / div >
< div className = "dialogue-text" >
2025-05-19 01:22:20 +07:00
< strong > ต ิ วเตอร ์ : < / strong > { aiReply || < em > กำล ั งรอคำถามของค ุ ณ < / em > }
2025-05-16 16:27:50 +07:00
< / div >
< / div >
{ isRecording && (
< canvas
ref = { canvasRef }
width = { 300 }
height = { 60 }
className = "waveform-canvas"
/ >
) }
< div className = "button-container" >
< button
onClick = { toggleRecording }
className = { ` control-button ${ isRecording ? 'recording' : 'idle' } ` }
>
2025-05-16 21:46:23 +07:00
{ isRecording ? 'ฉันพูดเสร็จแล้ว' : 'พูด' }
2025-05-16 16:27:50 +07:00
< / button >
< / div >
2025-05-15 16:50:55 +07:00
< / div >
2025-05-12 21:54:32 +07:00
< / div >
) ;
}
2025-05-16 16:27:50 +07:00
export default App ;