dramaling-vocab-learning/frontend/components/VoiceRecorder.tsx

396 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
);
}