Initial commit

This commit is contained in:
theshy
2025-07-31 17:05:07 +08:00
parent 8fab3b19cc
commit 24f21144ab
91 changed files with 16311 additions and 159 deletions

View 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>
</>
);
}