"use client"; import React, { useState, useRef, useEffect } from "react"; import { Mic, MicOff, Upload, Play, Pause, Trash2, X, Volume2, } from "lucide-react"; import { Tooltip, Upload as AntdUpload, message } from "antd"; import { InboxOutlined } from "@ant-design/icons"; import WaveSurfer from "wavesurfer.js"; import { getAudioDuration, useUploadFile, } from "../../app/service/domain/service"; // 自定义样式 const audioRecorderStyles = ` .slider { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; } .slider::-webkit-slider-track { background: rgba(255, 255, 255, 0.2); height: 4px; border-radius: 2px; } .slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; background: #3b82f6; height: 12px; width: 12px; border-radius: 50%; cursor: pointer; transition: all 0.2s ease; } .slider::-webkit-slider-thumb:hover { background: #2563eb; transform: scale(1.1); } .slider::-moz-range-track { background: rgba(255, 255, 255, 0.2); height: 4px; border-radius: 2px; border: none; } .slider::-moz-range-thumb { background: #3b82f6; height: 12px; width: 12px; border-radius: 50%; cursor: pointer; border: none; transition: all 0.2s ease; } .slider::-moz-range-thumb:hover { background: #2563eb; transform: scale(1.1); } /* 音量滑块样式 */ .volume-slider { -webkit-appearance: none; appearance: none; background: transparent; cursor: pointer; } .volume-slider::-webkit-slider-track { background: transparent; height: 16px; border-radius: 8px; } .volume-slider::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 0; height: 0; box-shadow: -100px 0 5px 100px #92ff77, -100px 0px 20px 100px #92ff77; cursor: pointer; } .volume-slider::-moz-range-track { background: transparent; height: 16px; border-radius: 8px; border: none; } .volume-slider::-moz-range-thumb { width: 0; height: 0; border-radius: 0; border: none; box-shadow: -100px 0 5px 100px #92ff77, -100px 0px 20px 100px #92ff77; } `; interface AudioRecorderProps { /** 当前音频URL */ audioUrl?: string; /** 录制完成回调 */ onAudioRecorded: (audioBlob: Blob, audioUrl: string) => void; /** 删除音频回调 */ onAudioDeleted: () => void; /** 组件标题 */ title?: string; /** 是否显示关闭按钮 */ showCloseButton?: boolean; /** 关闭回调 */ onClose?: () => void; } /** * 音频录制组件,支持录制、上传和播放功能 */ export function AudioRecorder({ audioUrl, onAudioRecorded, onAudioDeleted, showCloseButton = false, onClose, }: AudioRecorderProps) { const [mode, setMode] = useState<"upload" | "record">("upload"); // 当前模式:上传或录制 const [isRecording, setIsRecording] = useState(false); const [isPlaying, setIsPlaying] = useState(false); const [volume, setVolume] = useState(1); const [isMuted, setIsMuted] = useState(false); const [recordingTime, setRecordingTime] = useState(0); // 录制时长(秒) const [isUploadingRecording, setIsUploadingRecording] = useState(false); // 录制音频上传状态 const { uploadFile, isUploading } = useUploadFile(); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const recordingTimerRef = useRef(null); // 录制计时器引用 const actualRecordingTimeRef = useRef(0); // 实际录制时长引用,用于准确获取时长 // 清理计时器 useEffect(() => { return () => { if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); } }; }, []); // 开始录制 const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); // 检查支持的音频格式,优先使用 webm const mimeType = MediaRecorder.isTypeSupported("audio/webm") ? "audio/webm" : "audio/mp4"; const mediaRecorder = new MediaRecorder(stream, { mimeType }); mediaRecorderRef.current = mediaRecorder; chunksRef.current = []; mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { chunksRef.current.push(event.data); } }; mediaRecorder.onstop = async () => { const audioBlob = new Blob(chunksRef.current, { type: mimeType }); // 先停止计时器,然后获取最终时长 if (recordingTimerRef.current) { clearInterval(recordingTimerRef.current); recordingTimerRef.current = null; } // 使用引用值获取准确的录制时长 const duration = actualRecordingTimeRef.current; console.log("录制时长:", duration, "秒"); // 无论时长是否符合要求,都要释放麦克风权限 stream.getTracks().forEach((track) => track.stop()); if (duration > 20 || duration < 10) { message.warning( "Please keep your audio between 10 and 20 seconds to help us better learn your voice." ); return; } try { // 将 Blob 转换为 File 对象,以便上传 const audioFile = new File( [audioBlob], `recording_${Date.now()}.webm`, { type: mimeType } ); // 设置上传状态 setIsUploadingRecording(true); // 使用 uploadFile 上传到服务器 console.log("开始上传录制的音频文件..."); const uploadedUrl = await uploadFile(audioFile); console.log("音频上传成功,服务器URL:", uploadedUrl); // 使用服务器返回的真实URL地址 onAudioRecorded(audioBlob, uploadedUrl); } catch (error) { console.error("音频上传失败:", error); message.error("音频上传失败,请重试"); return; } finally { setIsUploadingRecording(false); } // 重置计时器状态 setRecordingTime(0); actualRecordingTimeRef.current = 0; // 重置引用值 setIsRecording(false); }; mediaRecorder.start(); setIsRecording(true); setRecordingTime(0); // 重置计时器 actualRecordingTimeRef.current = 0; // 重置引用值 // 开始计时 recordingTimerRef.current = setInterval(() => { setRecordingTime((prev) => prev + 1); actualRecordingTimeRef.current += 1; // 同时更新引用值 }, 1000); } catch (error) { console.error("录制失败:", error); } }; // 停止录制 const stopRecording = () => { if (mediaRecorderRef.current && isRecording) { mediaRecorderRef.current.stop(); setIsRecording(false); // 注意:计时器清理现在在 onstop 回调中处理 } }; // 播放/暂停控制 const togglePlay = () => { setIsPlaying(!isPlaying); }; // 音量调节 const handleVolumeChange = (e: React.ChangeEvent) => { const newVolume = parseFloat(e.target.value); // 确保音量值在 0-1 范围内 const clampedVolume = Math.max(0, Math.min(1, newVolume)); console.log("newVolume", newVolume, "clampedVolume", clampedVolume); setVolume(clampedVolume); if (isMuted && clampedVolume > 0) { setIsMuted(false); } }; // 删除音频 const handleDelete = () => { setIsPlaying(false); onAudioDeleted(); }; // 渲染上传/录制状态 if (!audioUrl) { return ( <>
{/* 头部 - 只显示关闭按钮 */} {showCloseButton && (
)} {/* 主要内容区域 */}
{mode === "upload" ? ( // 上传模式
{ console.log("beforeUpload 被调用:", file); // 移除 return false,让文件能够正常进入 customRequest const duration = await getAudioDuration(file); console.log("duration", duration); if (duration > 20 || duration < 10) { message.warning( "Please keep your audio between 10 and 20 seconds to help us better learn your voice." ); return false; } return true; }} customRequest={async ({ file, onSuccess, onError }) => { console.log("customRequest 被调用,文件:", file); try { const fileObj = file as File; console.log( "开始上传文件:", fileObj.name, fileObj.type, fileObj.size ); if (fileObj && fileObj.type.startsWith("audio/")) { // 使用 hook 上传文件到七牛云 console.log("调用 uploadFile hook..."); const uploadedUrl = await uploadFile(fileObj); console.log("上传成功,URL:", uploadedUrl); // 上传成功后,调用回调函数 onAudioRecorded(fileObj, uploadedUrl); onSuccess?.(uploadedUrl); } else { console.log("文件类型不是音频:", fileObj?.type); const error = new Error("文件类型不是音频文件"); onError?.(error); } } catch (error) { console.error("上传失败:", error); // 上传失败时直接报告错误,不使用本地文件作为备选 onError?.(error as Error); } }} showUploadList={false} className="bg-transparent border-dashed border-white/20 hover:border-white/40" disabled={isUploading} onChange={(info) => { console.log("Upload onChange 事件:", info); }} onDrop={(e) => { console.log("文件拖拽事件:", e); }} >
{isUploading ? "Uploading..." : "Drag audio file here or click to upload"}
) : ( // 录制模式
{isRecording ? ( // 录制中状态
Recording...
{/* 录制状态指示器和计时器 */}
Recording... {Math.floor(recordingTime / 60) .toString() .padStart(2, "0")} :{(recordingTime % 60).toString().padStart(2, "0")}
) : isUploadingRecording ? ( // 录制音频上传中状态
Uploading recorded audio...
) : ( // 录制准备状态
Click to start recording
)}
)}
{/* 底部模式切换图标 */}
); } // 渲染播放状态 return ( <>
{/* 大旋转的播放圆盘 */}
{/* 外层边框 - 唱片边缘 */}
{/* 唱片主体 - 纯黑色唱片面 */}
{/* 唱片纹理 - 细密的同心圆 */}
{/* 中心孔洞 - 透明圆环,显示背景色 */}
{/* MovieFlow 字母 - 环形文本效果 */}
{/* 定义渐变 */} {/* 圆形路径 - 减小半径让文字更靠近中心 */} {/* 环形文本 - 调整起始位置让文字更居中 */} MovieFlow
{/* 唱片光泽效果 */}
{/* 播放控制 */} {/* 头部 - 只显示操作按钮 */}
{showCloseButton && ( )}
{/* 音频设置 */}
{/* WaveSurfer 波形图区域 */}
); } /** * 音频波形播放器组件的属性接口 */ interface WaveformPlayerProps { /** 音频文件的URL地址 */ audioUrl: string; /** 当前播放状态,true表示正在播放,false表示暂停或停止 */ isPlaying: boolean; /** 播放状态变化时的回调函数,用于同步外部播放状态 */ onPlayStateChange: (isPlaying: boolean) => void; /** 音量大小,范围0-1,默认为1(最大音量) */ volume?: number; /** 是否静音,true表示静音,false表示有声音,默认为false */ isMuted?: boolean; } /** * 音频波形播放器组件 * * 使用 WaveSurfer.js 库创建可视化的音频波形播放器,支持播放控制、音量调节和静音功能。 * 组件会自动同步外部传入的播放状态,确保播放控制的一致性。 * * @param {string} audioUrl - 音频文件的URL地址 * @param {boolean} isPlaying - 当前播放状态 * @param {function} onPlayStateChange - 播放状态变化回调函数 * @param {number} [volume=1] - 音量大小,范围0-1 * @param {boolean} [isMuted=false] - 是否静音 * @returns {JSX.Element} 渲染的波形播放器组件 * ``` */ function WaveformPlayer({ audioUrl, isPlaying, onPlayStateChange, volume = 1, isMuted = false, }: WaveformPlayerProps) { /** 容器DOM元素的引用,用于挂载WaveSurfer实例 */ const containerRef = useRef(null); /** WaveSurfer实例的引用,用于控制音频播放 */ const wavesurferRef = useRef(null); /** * 初始化WaveSurfer实例 * 当audioUrl变化时重新创建实例 */ useEffect(() => { if (!containerRef.current || !audioUrl) return; // 创建Canvas渐变 const canvas = document.createElement("canvas"); const ctx = canvas.getContext("2d"); if (!ctx) return; const gradient = ctx.createLinearGradient(0, 0, 0, 64); // 64是波形高度 gradient.addColorStop(0, "rgb(106, 244, 249)"); // 顶部:青色 gradient.addColorStop(0.5, "rgb(152, 151, 254)"); // 中间:蓝紫色 gradient.addColorStop(1, "rgb(199, 59, 255)"); // 底部:紫色 // 创建WaveSurfer实例并配置样式 const ws = WaveSurfer.create({ container: containerRef.current, waveColor: gradient, // 使用渐变作为波形颜色 progressColor: "rgb(152, 151, 254)", // 播放进度颜色 - 半透明白色,更优雅 cursorColor: "rgb(152, 151, 254)", // 播放光标颜色 - 紫色 height: 64, // 波形高度 barWidth: 2, // 波形条宽度 barGap: 1, // 波形条间距 normalize: true, // 自动标准化波形 url: audioUrl, // 音频文件URL }); // 监听播放状态变化事件,同步到外部状态 ws.on("play", () => onPlayStateChange(true)); // 开始播放 ws.on("pause", () => onPlayStateChange(false)); // 暂停播放 ws.on("finish", () => onPlayStateChange(false)); // 播放结束 wavesurferRef.current = ws; // 组件卸载时销毁WaveSurfer实例,释放资源 return () => { ws.destroy(); }; }, [audioUrl, onPlayStateChange]); /** * 同步外部播放状态和音量设置 * 当isPlaying、volume或isMuted变化时,同步到WaveSurfer实例 */ useEffect(() => { if (!wavesurferRef.current) return; // 同步播放状态:如果外部状态为播放但WaveSurfer未播放,则开始播放 if (isPlaying && !wavesurferRef.current.isPlaying()) { wavesurferRef.current.play(); } // 如果外部状态为暂停但WaveSurfer正在播放,则暂停播放 else if (!isPlaying && wavesurferRef.current.isPlaying()) { wavesurferRef.current.pause(); } // 设置音量:静音时设为0,否则使用传入的音量值 const currentVolume = isMuted ? 0 : volume; wavesurferRef.current.setVolume(currentVolume); }, [isPlaying, volume, isMuted]); return (
); } function VolumeSlider({ volume, onVolumeChange, }: { volume: number; onVolumeChange: (e: React.ChangeEvent) => void; }) { const [isHovered, setIsHovered] = useState(false); return (
); }