400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
'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
|
||
}
|
||
} |