Files
record-app-next/components/AudioRecorder.tsx
2025-07-31 17:05:07 +08:00

535 lines
18 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 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>
</>
);
}