396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
'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;
|
||
instructionText?: string;
|
||
onScoreReceived?: (score: PronunciationScore) => void;
|
||
onRecordingComplete?: (audioBlob: Blob) => void;
|
||
maxDuration?: number;
|
||
userLevel?: string;
|
||
className?: string;
|
||
}
|
||
|
||
export default function VoiceRecorder({
|
||
targetText,
|
||
targetTranslation,
|
||
exampleImage,
|
||
instructionText,
|
||
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<Blob | null>(null);
|
||
const [audioUrl, setAudioUrl] = useState<string | null>(null);
|
||
const [score, setScore] = useState<PronunciationScore | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||
const streamRef = useRef<MediaStream | null>(null);
|
||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||
const audioRef = useRef<HTMLAudioElement>(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 (
|
||
<div className={`voice-recorder ${className}`}>
|
||
{/* 隱藏的音頻元素 */}
|
||
<audio ref={audioRef} />
|
||
|
||
|
||
|
||
{/* 目標文字顯示 */}
|
||
<div className="mb-6">
|
||
<div className="p-4 bg-gray-50 rounded-lg">
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="flex-1">
|
||
<div className="text-gray-800 text-lg mb-2">{targetText}</div>
|
||
{targetTranslation && (
|
||
<div className="text-gray-600 text-base">{targetTranslation}</div>
|
||
)}
|
||
</div>
|
||
<AudioPlayer
|
||
text={targetText}
|
||
className="flex-shrink-0 mt-1"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Instruction Text */}
|
||
{instructionText && (
|
||
<div className="mb-6">
|
||
<p className="text-lg text-gray-700 text-left">
|
||
{instructionText}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* 錄音控制區 */}
|
||
<div className="p-6 border-2 border-dashed border-gray-300 rounded-xl">
|
||
<div className="flex flex-col items-center gap-4">
|
||
{/* 錄音按鈕 */}
|
||
<button
|
||
onClick={isRecording ? stopRecording : startRecording}
|
||
disabled={isProcessing}
|
||
className={`
|
||
w-20 h-20 rounded-full flex items-center justify-center transition-all
|
||
${isRecording
|
||
? 'bg-red-500 hover:bg-red-600 animate-pulse'
|
||
: 'bg-blue-500 hover:bg-blue-600'
|
||
}
|
||
${isProcessing ? 'opacity-50 cursor-not-allowed' : ''}
|
||
text-white shadow-lg
|
||
`}
|
||
title={isRecording ? 'Stop Recording' : 'Start Recording'}
|
||
>
|
||
{isRecording ? <Square size={32} /> : <Mic size={32} />}
|
||
</button>
|
||
|
||
{/* 錄音狀態 */}
|
||
{isRecording && (
|
||
<div className="text-center">
|
||
<div className="text-red-600 font-semibold">
|
||
🔴 錄音中...
|
||
</div>
|
||
<div className="text-sm text-gray-600">
|
||
{formatTime(recordingTime)} / {formatTime(maxDuration)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 播放和評估按鈕 */}
|
||
{audioBlob && !isRecording && (
|
||
<div className="flex gap-3">
|
||
<button
|
||
onClick={playRecording}
|
||
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||
>
|
||
<Play size={16} />
|
||
播放錄音
|
||
</button>
|
||
<button
|
||
onClick={evaluatePronunciation}
|
||
disabled={isProcessing}
|
||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
|
||
>
|
||
<Upload size={16} />
|
||
{isProcessing ? '評估中...' : '評估發音'}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* 處理狀態 */}
|
||
{isProcessing && (
|
||
<div className="flex items-center gap-2 text-blue-600">
|
||
<div className="animate-spin w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full" />
|
||
正在評估您的發音...
|
||
</div>
|
||
)}
|
||
|
||
{/* 錯誤顯示 */}
|
||
{error && (
|
||
<div className="text-red-600 bg-red-50 p-3 rounded-lg text-center max-w-md">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{/* 評分結果 */}
|
||
{score && (
|
||
<div className="score-display w-full max-w-md mx-auto mt-4 p-4 bg-white border rounded-lg shadow">
|
||
{/* 總分 */}
|
||
<div className="text-center mb-4">
|
||
<div className={`text-4xl font-bold ${getScoreColor(score.overall)}`}>
|
||
{score.overall}
|
||
</div>
|
||
<div className="text-sm text-gray-600">總體評分</div>
|
||
</div>
|
||
|
||
{/* 詳細評分 */}
|
||
<div className="grid grid-cols-2 gap-3 mb-4 text-sm">
|
||
<div className="flex justify-between">
|
||
<span>準確度:</span>
|
||
<span className={getScoreColor(score.accuracy)}>{score.accuracy.toFixed(1)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>流暢度:</span>
|
||
<span className={getScoreColor(score.fluency)}>{score.fluency.toFixed(1)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>完整度:</span>
|
||
<span className={getScoreColor(score.completeness)}>{score.completeness.toFixed(1)}</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span>音調:</span>
|
||
<span className={getScoreColor(score.prosody)}>{score.prosody.toFixed(1)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 改進建議 */}
|
||
{score.suggestions.length > 0 && (
|
||
<div className="suggestions">
|
||
<h4 className="font-semibold mb-2 text-gray-800">💡 改進建議:</h4>
|
||
<ul className="text-sm text-gray-700 space-y-1">
|
||
{score.suggestions.map((suggestion, index) => (
|
||
<li key={index} className="flex items-start gap-2">
|
||
<span className="text-blue-500">•</span>
|
||
{suggestion}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
} |