dramaling-vocab-learning/frontend/components/shared/AudioRecorder.tsx

400 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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, useCallback, useRef, useEffect } from 'react'
export interface AudioRecorderState {
isRecording: boolean
audioBlob: Blob | null
recordingTime: number
isProcessing: boolean
error: string | null
}
interface AudioRecorderProps {
onRecordingComplete: (audioBlob: Blob) => void
onRecordingStart?: () => void
onRecordingStop?: () => void
onError?: (error: string) => void
maxDuration?: number // 最大錄音時長(秒)
disabled?: boolean
className?: string
}
export function AudioRecorder({
onRecordingComplete,
onRecordingStart,
onRecordingStop,
onError,
maxDuration = 30,
disabled = false,
className = ""
}: AudioRecorderProps) {
const [state, setState] = useState<AudioRecorderState>({
isRecording: false,
audioBlob: null,
recordingTime: 0,
isProcessing: false,
error: null
})
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const timerRef = useRef<NodeJS.Timeout | null>(null)
// 清理函數
const cleanup = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop())
streamRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
audioChunksRef.current = []
}, [])
// 組件卸載時清理
useEffect(() => {
return cleanup
}, [cleanup])
// 開始錄音
const startRecording = useCallback(async () => {
if (disabled) return
try {
setState(prev => ({ ...prev, error: null, isProcessing: true }))
// 請求麥克風權限
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000 // Azure Speech Services 推薦採樣率
}
})
streamRef.current = stream
audioChunksRef.current = []
// 創建 MediaRecorder
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus' // 現代瀏覽器支援的格式
})
mediaRecorderRef.current = mediaRecorder
// 處理錄音資料
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
// 錄音完成處理
mediaRecorder.onstop = () => {
setState(prev => ({ ...prev, isProcessing: true }))
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
setState(prev => ({
...prev,
audioBlob,
isRecording: false,
isProcessing: false
}))
onRecordingComplete(audioBlob)
cleanup()
}
// 開始錄音
mediaRecorder.start(1000) // 每秒收集一次資料
// 啟動計時器
let seconds = 0
timerRef.current = setInterval(() => {
seconds++
setState(prev => ({ ...prev, recordingTime: seconds }))
// 達到最大時長自動停止
if (seconds >= maxDuration) {
stopRecording()
}
}, 1000)
setState(prev => ({
...prev,
isRecording: true,
recordingTime: 0,
isProcessing: false
}))
onRecordingStart?.()
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '麥克風存取失敗'
setState(prev => ({
...prev,
error: errorMessage,
isProcessing: false
}))
onError?.(errorMessage)
cleanup()
}
}, [disabled, maxDuration, onRecordingComplete, onRecordingStart, onError, cleanup])
// 停止錄音
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop()
onRecordingStop?.()
}
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
}, [onRecordingStop])
// 格式化時間顯示
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
return (
<div className={`text-center ${className}`}>
{/* 錯誤顯示 */}
{state.error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mb-4">
<p className="text-red-700 text-sm"> {state.error}</p>
</div>
)}
{/* 錄音按鈕 */}
<div className="mb-4">
{!state.isRecording ? (
<button
onClick={startRecording}
disabled={disabled || state.isProcessing}
className={`px-6 py-3 rounded-full font-medium transition-all ${
disabled || state.isProcessing
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
: 'bg-red-500 hover:bg-red-600 text-white transform hover:scale-105'
}`}
>
{state.isProcessing ? (
<span className="flex items-center gap-2">
<div className="animate-spin w-4 h-4 border-2 border-white border-t-transparent rounded-full"></div>
...
</span>
) : (
<span className="flex items-center gap-2">
🎤
</span>
)}
</button>
) : (
<button
onClick={stopRecording}
className="px-6 py-3 bg-red-600 hover:bg-red-700 text-white rounded-full font-medium animate-pulse transition-all"
>
<span className="flex items-center gap-2">
</span>
</button>
)}
</div>
{/* 錄音狀態顯示 */}
{state.isRecording && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4">
<div className="flex items-center justify-center gap-3 mb-2">
<div className="w-3 h-3 bg-red-500 rounded-full animate-ping"></div>
<span className="text-red-700 font-medium"></span>
</div>
<div className="text-red-600 text-xl font-mono">
{formatTime(state.recordingTime)}
</div>
<div className="text-red-500 text-sm mt-2">
{formatTime(maxDuration)}
</div>
{/* 進度條 */}
<div className="w-full bg-red-100 rounded-full h-2 mt-3">
<div
className="bg-red-500 h-2 rounded-full transition-all duration-1000"
style={{ width: `${(state.recordingTime / maxDuration) * 100}%` }}
></div>
</div>
</div>
)}
{/* 錄音完成提示 */}
{state.audioBlob && !state.isRecording && (
<div className="bg-green-50 border border-green-200 rounded-lg p-3 mt-4">
<p className="text-green-700 text-sm">
: {formatTime(state.recordingTime)}
</p>
</div>
)}
{/* 瀏覽器兼容性提示 */}
{!navigator.mediaDevices?.getUserMedia && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mt-4">
<p className="text-yellow-700 text-sm">
使 Chrome Firefox
</p>
</div>
)}
</div>
)
}
// Hook 版本,提供更靈活的使用方式
export function useAudioRecorder(maxDuration: number = 30) {
const [state, setState] = useState<AudioRecorderState>({
isRecording: false,
audioBlob: null,
recordingTime: 0,
isProcessing: false,
error: null
})
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
const streamRef = useRef<MediaStream | null>(null)
const audioChunksRef = useRef<Blob[]>([])
const timerRef = useRef<NodeJS.Timeout | null>(null)
const cleanup = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
if (streamRef.current) {
streamRef.current.getTracks().forEach(track => track.stop())
streamRef.current = null
}
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
mediaRecorderRef.current.stop()
}
audioChunksRef.current = []
}, [])
const startRecording = useCallback(async () => {
try {
setState(prev => ({ ...prev, error: null, isProcessing: true }))
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 16000
}
})
streamRef.current = stream
audioChunksRef.current = []
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus'
})
mediaRecorderRef.current = mediaRecorder
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data)
}
}
mediaRecorder.onstop = () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
setState(prev => ({
...prev,
audioBlob,
isRecording: false,
isProcessing: false
}))
cleanup()
}
mediaRecorder.start(1000)
let seconds = 0
timerRef.current = setInterval(() => {
seconds++
setState(prev => ({ ...prev, recordingTime: seconds }))
if (seconds >= maxDuration) {
stopRecording()
}
}, 1000)
setState(prev => ({
...prev,
isRecording: true,
recordingTime: 0,
isProcessing: false
}))
} catch (error) {
const errorMessage = error instanceof Error ? error.message : '錄音失敗'
setState(prev => ({
...prev,
error: errorMessage,
isProcessing: false
}))
cleanup()
}
}, [maxDuration, cleanup])
const stopRecording = useCallback(() => {
if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') {
mediaRecorderRef.current.stop()
}
if (timerRef.current) {
clearInterval(timerRef.current)
timerRef.current = null
}
}, [])
const resetRecording = useCallback(() => {
cleanup()
setState({
isRecording: false,
audioBlob: null,
recordingTime: 0,
isProcessing: false,
error: null
})
}, [cleanup])
// 清理函數
useEffect(() => {
return cleanup
}, [cleanup])
return {
...state,
startRecording,
stopRecording,
resetRecording
}
}