'use client'; import { useState, useRef, useCallback, useEffect } from 'react'; import { Mic, Square, Play, Upload } from 'lucide-react'; import AudioPlayer from './AudioPlayer'; export interface PronunciationScore { overall: number; accuracy: number; fluency: number; completeness: number; prosody: number; phonemes: PhonemeScore[]; suggestions: string[]; } export interface PhonemeScore { phoneme: string; score: number; suggestion?: string; } export interface VoiceRecorderProps { targetText: string; targetTranslation?: string; exampleImage?: string; onScoreReceived?: (score: PronunciationScore) => void; onRecordingComplete?: (audioBlob: Blob) => void; maxDuration?: number; userLevel?: string; className?: string; } export default function VoiceRecorder({ targetText, targetTranslation, exampleImage, onScoreReceived, onRecordingComplete, maxDuration = 30, // 30 seconds default userLevel = 'B1', className = '' }: VoiceRecorderProps) { const [isRecording, setIsRecording] = useState(false); const [isProcessing, setIsProcessing] = useState(false); const [recordingTime, setRecordingTime] = useState(0); const [audioBlob, setAudioBlob] = useState(null); const [audioUrl, setAudioUrl] = useState(null); const [score, setScore] = useState(null); const [error, setError] = useState(null); const mediaRecorderRef = useRef(null); const streamRef = useRef(null); const timerRef = useRef(null); const audioRef = useRef(null); // 檢查瀏覽器支援 const checkBrowserSupport = () => { if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { setError('Your browser does not support audio recording'); return false; } return true; }; // 開始錄音 const startRecording = useCallback(async () => { if (!checkBrowserSupport()) return; try { setError(null); setScore(null); setAudioBlob(null); setAudioUrl(null); // 請求麥克風權限 const stream = await navigator.mediaDevices.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, sampleRate: 16000 } }); streamRef.current = stream; // 設置 MediaRecorder const mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm;codecs=opus' }); const audioChunks: Blob[] = []; mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { audioChunks.push(event.data); } }; mediaRecorder.onstop = () => { const blob = new Blob(audioChunks, { type: 'audio/webm' }); setAudioBlob(blob); setAudioUrl(URL.createObjectURL(blob)); onRecordingComplete?.(blob); // 停止所有音軌 stream.getTracks().forEach(track => track.stop()); }; mediaRecorderRef.current = mediaRecorder; mediaRecorder.start(); setIsRecording(true); setRecordingTime(0); // 開始計時 timerRef.current = setInterval(() => { setRecordingTime(prev => { const newTime = prev + 1; if (newTime >= maxDuration) { stopRecording(); } return newTime; }); }, 1000); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to start recording'; setError(errorMessage); console.error('Recording error:', error); } }, [maxDuration, onRecordingComplete]); // 停止錄音 const stopRecording = useCallback(() => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); setIsRecording(false); if (timerRef.current) { clearInterval(timerRef.current); timerRef.current = null; } if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); streamRef.current = null; } } }, [isRecording]); // 播放錄音 const playRecording = useCallback(() => { if (audioUrl && audioRef.current) { audioRef.current.src = audioUrl; audioRef.current.play(); } }, [audioUrl]); // 評估發音 const evaluatePronunciation = useCallback(async () => { if (!audioBlob || !targetText) { setError('No audio to evaluate'); return; } try { setIsProcessing(true); setError(null); const formData = new FormData(); formData.append('audioFile', audioBlob, 'recording.webm'); formData.append('targetText', targetText); formData.append('userLevel', userLevel); const token = localStorage.getItem('token'); if (!token) { throw new Error('Authentication required'); } const response = await fetch('/api/audio/pronunciation/evaluate', { method: 'POST', headers: { 'Authorization': `Bearer ${token}` }, body: formData }); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } const result = await response.json(); if (result.error) { throw new Error(result.error); } setScore(result); onScoreReceived?.(result); } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to evaluate pronunciation'; setError(errorMessage); } finally { setIsProcessing(false); } }, [audioBlob, targetText, userLevel, onScoreReceived]); // 格式化時間 const formatTime = (seconds: number) => { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; }; // 獲取評分顏色 const getScoreColor = (score: number) => { if (score >= 90) return 'text-green-600'; if (score >= 80) return 'text-blue-600'; if (score >= 70) return 'text-yellow-600'; if (score >= 60) return 'text-orange-600'; return 'text-red-600'; }; // 清理資源 useEffect(() => { return () => { if (timerRef.current) { clearInterval(timerRef.current); } if (streamRef.current) { streamRef.current.getTracks().forEach(track => track.stop()); } if (audioUrl) { URL.revokeObjectURL(audioUrl); } }; }, [audioUrl]); return (
{/* 隱藏的音頻元素 */}