Initial commit
This commit is contained in:
534
components/AudioRecorder.tsx
Normal file
534
components/AudioRecorder.tsx
Normal file
@ -0,0 +1,534 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState, useRef, useEffect } from "react";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
import {
|
||||
getBestRecordingFormat,
|
||||
SUPPORTED_AUDIO_FORMATS,
|
||||
} from "@/lib/config/audio-config";
|
||||
|
||||
interface AudioRecorderProps {
|
||||
onRecordingComplete?: () => void;
|
||||
}
|
||||
|
||||
export default function AudioRecorder({
|
||||
onRecordingComplete,
|
||||
}: AudioRecorderProps) {
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isPaused, setIsPaused] = useState(false);
|
||||
const [recordingTime, setRecordingTime] = useState(0);
|
||||
const [audioBlob, setAudioBlob] = useState<Blob | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [audioLevel, setAudioLevel] = useState(0);
|
||||
const [recordingTitle, setRecordingTitle] = useState(""); // 录音标题
|
||||
const [isRenaming, setIsRenaming] = useState(false); // 是否正在重命名
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const timerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const animationFrameRef = useRef<number | null>(null);
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const sourceRef = useRef<MediaStreamAudioSourceNode | null>(null);
|
||||
|
||||
// 音频分析器设置
|
||||
const setupAudioAnalyzer = (stream: MediaStream) => {
|
||||
try {
|
||||
const audioContext = new AudioContext();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
|
||||
analyser.fftSize = 256;
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
|
||||
source.connect(analyser);
|
||||
|
||||
audioContextRef.current = audioContext;
|
||||
analyserRef.current = analyser;
|
||||
|
||||
return true; // 表示设置成功
|
||||
} catch (error) {
|
||||
console.error("音频分析器设置失败:", error);
|
||||
return false; // 表示设置失败
|
||||
}
|
||||
};
|
||||
|
||||
// 更新音频电平
|
||||
const updateAudioLevel = () => {
|
||||
if (!analyserRef.current || !isRecording || isPaused) {
|
||||
setAudioLevel(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const dataArray = new Uint8Array(analyserRef.current.frequencyBinCount);
|
||||
analyserRef.current.getByteFrequencyData(dataArray);
|
||||
|
||||
// 计算平均音量
|
||||
const average =
|
||||
dataArray.reduce((sum, value) => sum + value, 0) / dataArray.length;
|
||||
const normalizedLevel = Math.min(average / 128, 1);
|
||||
|
||||
setAudioLevel(normalizedLevel);
|
||||
|
||||
if (isRecording && !isPaused) {
|
||||
animationFrameRef.current = requestAnimationFrame(updateAudioLevel);
|
||||
}
|
||||
};
|
||||
|
||||
// 清理音频分析器
|
||||
const cleanupAudioAnalyzer = () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
audioContextRef.current.close();
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
setAudioLevel(0);
|
||||
};
|
||||
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
// 录音参数直接写死
|
||||
const constraints = {
|
||||
audio: {
|
||||
sampleRate: 48000,
|
||||
channelCount: 2,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
};
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
||||
streamRef.current = stream;
|
||||
|
||||
// 录音格式直接写死为 webm
|
||||
const mimeType = "audio/webm;codecs=opus";
|
||||
|
||||
// 检查浏览器是否支持该格式
|
||||
if (!MediaRecorder.isTypeSupported(mimeType)) {
|
||||
console.warn(`不支持的格式: ${mimeType},使用默认格式`);
|
||||
// 使用默认格式
|
||||
const defaultFormat = getBestRecordingFormat();
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: defaultFormat.mimeType,
|
||||
});
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
} else {
|
||||
const mediaRecorder = new MediaRecorder(stream, {
|
||||
mimeType: mimeType,
|
||||
});
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
}
|
||||
|
||||
const audioChunks: Blob[] = [];
|
||||
|
||||
mediaRecorderRef.current.ondataavailable = (event) => {
|
||||
if (event.data.size > 0) {
|
||||
audioChunks.push(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorderRef.current.onstop = () => {
|
||||
// 使用实际使用的 MIME 类型
|
||||
const actualMimeType =
|
||||
mediaRecorderRef.current?.mimeType || "audio/webm";
|
||||
const audioBlob = new Blob(audioChunks, {
|
||||
type: actualMimeType,
|
||||
});
|
||||
setAudioBlob(audioBlob);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
cleanupAudioAnalyzer();
|
||||
};
|
||||
|
||||
// 设置音频分析器
|
||||
let analyzerSetup = false;
|
||||
try {
|
||||
analyzerSetup = setupAudioAnalyzer(stream);
|
||||
if (analyzerSetup) {
|
||||
// 音频分析器设置成功
|
||||
}
|
||||
} catch (error) {
|
||||
// 音频分析器设置失败,但不影响录音功能
|
||||
return false; // 表示设置失败
|
||||
}
|
||||
|
||||
// 开始录音
|
||||
mediaRecorderRef.current.start();
|
||||
setIsRecording(true);
|
||||
setIsPaused(false);
|
||||
setRecordingTime(0);
|
||||
|
||||
// 开始计时
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
|
||||
// 开始音频分析
|
||||
if (analyzerSetup) {
|
||||
updateAudioLevel();
|
||||
}
|
||||
} catch (error) {
|
||||
// 无法访问麦克风
|
||||
alert("无法访问麦克风,请检查权限设置。");
|
||||
}
|
||||
};
|
||||
|
||||
const pauseRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
if (isPaused) {
|
||||
// 继续录音
|
||||
mediaRecorderRef.current.resume();
|
||||
setIsPaused(false);
|
||||
// 重新启动计时器
|
||||
timerRef.current = setInterval(() => {
|
||||
setRecordingTime((prev) => prev + 1);
|
||||
}, 1000);
|
||||
// 重新开始音频分析
|
||||
updateAudioLevel();
|
||||
} else {
|
||||
// 暂停录音
|
||||
mediaRecorderRef.current.pause();
|
||||
setIsPaused(true);
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// 停止音频分析
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
animationFrameRef.current = null;
|
||||
}
|
||||
setAudioLevel(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const stopRecording = () => {
|
||||
if (mediaRecorderRef.current) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
// setIsAnalyzing(false); // This line is removed
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// 清理音频分析器
|
||||
cleanupAudioAnalyzer();
|
||||
// 停止音频流
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
// 保持录音时长,不清零
|
||||
}
|
||||
};
|
||||
|
||||
const cancelRecording = () => {
|
||||
if (mediaRecorderRef.current) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
setIsPaused(false);
|
||||
// 停止计时器
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
// 清理音频分析器
|
||||
cleanupAudioAnalyzer();
|
||||
// 停止音频流
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
setAudioBlob(null);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadRecording = async () => {
|
||||
if (!audioBlob) return;
|
||||
|
||||
setIsUploading(true);
|
||||
try {
|
||||
// 直接上传文件到后端,让后端处理 S3 上传
|
||||
const formData = new FormData();
|
||||
formData.append("audio", audioBlob, `recording-${Date.now()}.webm`);
|
||||
formData.append("duration", recordingTime.toString());
|
||||
|
||||
// 添加录音标题
|
||||
const title =
|
||||
recordingTitle.trim() || `录音 ${new Date().toLocaleString("zh-CN")}`;
|
||||
formData.append("title", title);
|
||||
|
||||
const response = await fetch("/api/recordings/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert("录音上传成功!");
|
||||
setAudioBlob(null);
|
||||
setRecordingTitle(""); // 清空录音标题
|
||||
// 保持录音时长用于显示,不重置为0
|
||||
// 触发父组件刷新
|
||||
console.log("录音上传成功,触发刷新事件");
|
||||
window.dispatchEvent(new CustomEvent("recording-uploaded"));
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || "保存录音记录失败");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("上传失败:", error);
|
||||
alert(`上传失败:${error instanceof Error ? error.message : "请重试"}`);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
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")}`;
|
||||
};
|
||||
|
||||
// 生成波形条
|
||||
const generateWaveformBars = () => {
|
||||
const bars = [];
|
||||
const barCount = 20;
|
||||
const baseHeight = 4;
|
||||
|
||||
for (let i = 0; i < barCount; i++) {
|
||||
const height =
|
||||
isRecording && !isPaused
|
||||
? baseHeight + audioLevel * 20 * Math.random()
|
||||
: baseHeight;
|
||||
|
||||
bars.push(
|
||||
<div
|
||||
key={i}
|
||||
className="bg-blue-500 rounded-full transition-all duration-100"
|
||||
style={{
|
||||
height: `${height}px`,
|
||||
width: "3px",
|
||||
opacity: isRecording && !isPaused ? 0.8 : 0.3,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return bars;
|
||||
};
|
||||
|
||||
// 组件卸载时清理
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupAudioAnalyzer();
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-6 border border-gray-100 dark:border-gray-700">
|
||||
<div className="text-center">
|
||||
<h3 className="text-xl font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
录音机
|
||||
</h3>
|
||||
|
||||
{/* 录音状态指示器 */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full transition-colors ${
|
||||
isRecording && !isPaused
|
||||
? "bg-red-500 animate-pulse"
|
||||
: isPaused
|
||||
? "bg-yellow-500"
|
||||
: "bg-gray-300"
|
||||
}`}
|
||||
></div>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{!isRecording ? "准备就绪" : isPaused ? "已暂停" : "录音中..."}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 波形可视化 */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-center items-end gap-1 h-8">
|
||||
{generateWaveformBars()}
|
||||
</div>
|
||||
{isRecording && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{isPaused ? "已暂停" : "录音中..."}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRecording && (
|
||||
<div className="mb-4">
|
||||
<div className="text-2xl font-mono text-red-500">
|
||||
{formatTime(recordingTime)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
{isPaused ? "已暂停" : "录音中..."}
|
||||
</div>
|
||||
{/* 录音进度条 */}
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-600 rounded-full h-2 mb-2">
|
||||
<div
|
||||
className="bg-red-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min((recordingTime / 300) * 100, 100)}%`, // 5分钟为100%
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-gray-500">
|
||||
进度: {Math.round((recordingTime / 300) * 100)}% (最大5分钟)
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center gap-4">
|
||||
{!isRecording ? (
|
||||
<button
|
||||
onClick={startRecording}
|
||||
className="bg-red-500 hover:bg-red-600 text-white px-6 py-3 rounded-full font-medium transition-colors"
|
||||
>
|
||||
开始录音
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={pauseRecording}
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
|
||||
>
|
||||
{isPaused ? "继续" : "暂停"}
|
||||
</button>
|
||||
<button
|
||||
onClick={stopRecording}
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
|
||||
>
|
||||
停止
|
||||
</button>
|
||||
<button
|
||||
onClick={cancelRecording}
|
||||
className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
|
||||
>
|
||||
取消录音
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{audioBlob && (
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* 录音标题 */}
|
||||
<div className="text-center">
|
||||
{isRenaming ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={recordingTitle}
|
||||
onChange={(e) => setRecordingTitle(e.target.value)}
|
||||
placeholder="输入录音标题"
|
||||
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-center min-w-0 flex-1 max-w-xs"
|
||||
maxLength={100}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (recordingTitle.trim()) {
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}}
|
||||
className="px-3 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setRecordingTitle("");
|
||||
setIsRenaming(false);
|
||||
}}
|
||||
className="px-3 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{recordingTitle || "录音"}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => setIsRenaming(true)}
|
||||
className="p-1 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
|
||||
title="重命名"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<audio controls className="w-full max-w-md mx-auto">
|
||||
<source
|
||||
src={URL.createObjectURL(audioBlob)}
|
||||
type="audio/webm"
|
||||
/>
|
||||
您的浏览器不支持音频播放。
|
||||
</audio>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button
|
||||
onClick={uploadRecording}
|
||||
disabled={isUploading}
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-6 py-2 rounded-full font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{isUploading ? (
|
||||
<>
|
||||
<LoadingSpinner size="sm" color="white" />
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
"保存录音"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setAudioBlob(null);
|
||||
setRecordingTitle("");
|
||||
}}
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-full font-medium transition-colors"
|
||||
>
|
||||
取消录音
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user