Browse Source

录音模块06

pull/11591/head
子阳 4 months ago
parent
commit
107034956c
  1. 6
      config/routes.ts
  2. 338
      src/pages/AudioRecorder06/AudioRecorder.tsx
  3. 152
      src/pages/AudioRecorder06/Example.tsx
  4. 178
      src/pages/AudioRecorder06/README.md
  5. 158
      src/pages/AudioRecorder06/index.less

6
config/routes.ts

@ -65,9 +65,5 @@ export default [
path: '/spoken-practice',
component: './spokenPages',
},
{
component: '404',
layout: false,
path: './*',
},
{ name: 'audio-recorder', icon: 'audio', path: '/audio-recorder', component: './AudioRecorder06/Example', }, { component: '404', layout: false, path: './*', },
];

338
src/pages/AudioRecorder06/AudioRecorder.tsx

@ -0,0 +1,338 @@
import React, { useState, useRef, useEffect } from 'react';
import { Button, Modal, message } from 'antd';
import { AudioOutlined, CloseOutlined, CheckOutlined } from '@ant-design/icons';
import './index.less';
interface AudioSegment {
blob: Blob;
duration: number;
}
interface AudioRecorderProps {
maxDuration?: number; // 最大录音时长,默认60秒
onFinish?: (filePath: string) => void; // 录音完成回调
onCancel?: () => void; // 取消录音回调
modalMode?: boolean; // 是否为弹框模式
triggerButton?: React.ReactNode; // 弹框模式下的触发按钮
}
const AudioRecorder: React.FC<AudioRecorderProps> = ({
maxDuration = 60,
onFinish,
onCancel,
modalMode = false,
triggerButton,
}) => {
const [isRecording, setIsRecording] = useState(false);
const [segments, setSegments] = useState<AudioSegment[]>([]);
const [currentDuration, setCurrentDuration] = useState(0);
const [totalDuration, setTotalDuration] = useState(0);
const [isModalVisible, setIsModalVisible] = useState(false);
const [permissionGranted, setPermissionGranted] = useState<boolean | null>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]);
const startTimeRef = useRef<number>(0);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const streamRef = useRef<MediaStream | null>(null);
// 请求麦克风权限
const requestMicrophonePermission = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
setPermissionGranted(true);
streamRef.current = stream;
return stream;
} catch (error) {
console.error('麦克风权限请求失败:', error);
setPermissionGranted(false);
showMessage('error', '麦克风权限被拒绝,请检查浏览器设置');
return null;
}
};
// 开始录音
const startRecording = async () => {
if (isRecording) return;
// 如果还没有权限,先请求权限
if (permissionGranted === null) {
const stream = await requestMicrophonePermission();
if (!stream) return;
} else if (!permissionGranted) {
showMessage('error', '麦克风权限被拒绝,请检查浏览器设置');
return;
}
setIsRecording(true);
audioChunksRef.current = [];
startTimeRef.current = Date.now();
try {
// 如果之前的stream已经关闭,重新请求
let stream = streamRef.current;
if (!stream || stream.active === false) {
stream = await requestMicrophonePermission();
if (!stream) return;
}
const mediaRecorder = new MediaRecorder(stream, {
mimeType: 'audio/webm;codecs=opus',
});
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunksRef.current.push(event.data);
}
};
mediaRecorder.start(100); // 每100ms收集一次数据
mediaRecorderRef.current = mediaRecorder;
// 启动定时器更新当前时长
timerRef.current = setInterval(() => {
const duration = Math.floor((Date.now() - startTimeRef.current) / 1000);
setCurrentDuration(duration);
// 检查是否超过最大时长
if (duration >= maxDuration) {
stopRecording();
showMessage('warning', `已达到最大录音时长${maxDuration}`);
}
}, 100);
} catch (error) {
console.error('开始录音失败:', error);
setIsRecording(false);
showMessage('error', '开始录音失败,请重试');
}
};
// 停止录音
const stopRecording = () => {
if (!isRecording || !mediaRecorderRef.current) return;
setIsRecording(false);
clearInterval(timerRef.current!);
mediaRecorderRef.current.stop();
// 计算当前分段的时长
const duration = Math.floor((Date.now() - startTimeRef.current) / 1000);
// 保存当前分段
mediaRecorderRef.current.onstop = () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
const newSegment: AudioSegment = { blob: audioBlob, duration };
setSegments((prev) => [...prev, newSegment]);
setTotalDuration((prev) => prev + duration);
setCurrentDuration(0);
};
};
// 合并所有录音分段
const mergeSegments = async () => {
if (segments.length === 0) {
showMessage('warning', '没有录音数据');
return;
}
try {
// 创建一个新的Blob,包含所有分段的数据
const allBlobs = segments.map((segment) => segment.blob);
const mergedBlob = new Blob(allBlobs, { type: 'audio/webm' });
// 创建一个临时URL
const url = URL.createObjectURL(mergedBlob);
// 调用完成回调
if (onFinish) {
onFinish(url);
}
showMessage('success', '录音完成');
resetRecorder();
// 如果是弹框模式,关闭弹框
if (modalMode) {
setIsModalVisible(false);
}
} catch (error) {
console.error('合并录音分段失败:', error);
showMessage('error', '合并录音失败,请重试');
}
};
// 清空所有录音数据
const clearSegments = () => {
resetRecorder();
if (onCancel) {
onCancel();
}
showMessage('info', '录音已取消');
// 如果是弹框模式,关闭弹框
if (modalMode) {
setIsModalVisible(false);
}
};
// 重置录音器状态
const resetRecorder = () => {
setIsRecording(false);
setSegments([]);
setCurrentDuration(0);
setTotalDuration(0);
clearInterval(timerRef.current!);
// 停止所有轨道
if (streamRef.current) {
streamRef.current.getTracks().forEach((track) => track.stop());
streamRef.current = null;
}
mediaRecorderRef.current = null;
audioChunksRef.current = [];
setPermissionGranted(null);
};
// 显示提示消息
const showMessage = (type: 'success' | 'error' | 'warning' | 'info', content: string) => {
message[type](content, 1); // 1秒后自动消失
};
// 组件卸载时清理资源
useEffect(() => {
return () => {
resetRecorder();
};
}, []);
// 渲染录音按钮
const renderRecordButton = () => {
return (
<div
className={`audio-recorder-button ${isRecording ? 'recording' : ''}`}
onMouseDown={startRecording}
onMouseUp={stopRecording}
onMouseLeave={stopRecording}
onTouchStart={startRecording}
onTouchEnd={stopRecording}
>
<AudioOutlined />
<span>{isRecording ? '松开停止' : '长按录音'}</span>
</div>
);
};
// 渲染进度条
const renderProgressBar = () => {
const currentProgress = isRecording ? (currentDuration / maxDuration) * 100 : 0;
// 计算每个已录制分段的起始位置
const segmentPositions = segments.reduce((acc, segment, index) => {
const previousTotalDuration = segments.slice(0, index).reduce((sum, s) => sum + s.duration, 0);
const startPosition = (previousTotalDuration / maxDuration) * 100;
const segmentProgress = (segment.duration / maxDuration) * 100;
acc.push({ startPosition, segmentProgress });
return acc;
}, [] as { startPosition: number; segmentProgress: number }[]);
// 计算当前正在录制的分段的起始位置
const currentStartPosition = totalDuration > 0 ? (totalDuration / maxDuration) * 100 : 0;
return (
<div className="audio-recorder-progress">
<div className="audio-recorder-progress-bar">
{/* 已录制的分段 */}
{segmentPositions.map((position, index) => (
<div
key={index}
className="audio-recorder-progress-segment"
style={{
width: `${position.segmentProgress}%`,
left: `${position.startPosition}%`,
}}
/>
))}
{/* 当前正在录制的分段 */}
{isRecording && (
<div
className="audio-recorder-progress-current"
style={{
width: `${currentProgress}%`,
left: `${currentStartPosition}%`,
}}
/>
)}
</div>
<div className="audio-recorder-duration">
<span>{totalDuration + currentDuration}</span>
<span>/</span>
<span>{maxDuration}</span>
</div>
</div>
);
};
// 渲染操作按钮
const renderActionButtons = () => {
const hasData = segments.length > 0 || isRecording;
return (
<div className="audio-recorder-actions">
<Button
type="default"
icon={<CloseOutlined />}
onClick={clearSegments}
disabled={!hasData}
>
</Button>
<Button
type="primary"
icon={<CheckOutlined />}
onClick={mergeSegments}
disabled={!hasData}
>
</Button>
</div>
);
};
// 渲染录音组件内容
const renderRecorderContent = () => {
return (
<div className="audio-recorder-container">
<h3></h3>
{renderRecordButton()}
{renderProgressBar()}
{renderActionButtons()}
</div>
);
};
// 如果是弹框模式,渲染弹框
if (modalMode) {
return (
<>
<Button type="primary" onClick={() => setIsModalVisible(true)}>
{triggerButton || '开始录音'}
</Button>
<Modal
title="录音"
visible={isModalVisible}
onCancel={() => setIsModalVisible(false)}
footer={null}
>
{renderRecorderContent()}
</Modal>
</>
);
}
// 否则,直接渲染组件
return renderRecorderContent();
};
export default AudioRecorder;

152
src/pages/AudioRecorder06/Example.tsx

@ -0,0 +1,152 @@
import React, { useState } from 'react';
import AudioRecorder from './AudioRecorder';
import { Card, Tabs, Button, message } from 'antd';
import { PlayCircleOutlined } from '@ant-design/icons';
import './index.less';
const { TabPane } = Tabs;
const Example: React.FC = () => {
const [recordedFile, setRecordedFile] = useState<string | null>(null);
const [modalRecordedFile, setModalRecordedFile] = useState<string | null>(null);
// 处理录音完成
const handleRecordFinish = (filePath: string) => {
setRecordedFile(filePath);
message.success('录音完成,已保存录音文件');
};
// 处理录音取消
const handleRecordCancel = () => {
setRecordedFile(null);
message.info('录音已取消');
};
// 处理弹框模式录音完成
const handleModalRecordFinish = (filePath: string) => {
setModalRecordedFile(filePath);
message.success('弹框模式录音完成,已保存录音文件');
};
// 处理弹框模式录音取消
const handleModalRecordCancel = () => {
setModalRecordedFile(null);
message.info('弹框模式录音已取消');
};
// 播放录音
const playRecording = (filePath: string | null) => {
if (!filePath) {
message.warning('没有录音文件可以播放');
return;
}
try {
const audio = new Audio(filePath);
audio.play();
message.info('开始播放录音');
} catch (error) {
console.error('播放录音失败:', error);
message.error('播放录音失败,请重试');
}
};
return (
<div className="audio-recorder-example">
<h2>使</h2>
<Tabs defaultActiveKey="1" className="example-tabs">
{/* 直接嵌入模式 */}
<TabPane tab="直接嵌入模式" key="1">
<Card title="录音组件(直接嵌入)" bordered={false} className="example-card">
<AudioRecorder
maxDuration={60} // 最大录音时长,默认60秒
onFinish={handleRecordFinish}
onCancel={handleRecordCancel}
modalMode={false} // 直接嵌入模式
/>
{/* 录音结果展示 */}
{recordedFile && (
<div className="recording-result">
<h4></h4>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => playRecording(recordedFile)}
>
</Button>
<p>: {recordedFile}</p>
</div>
)}
</Card>
</TabPane>
{/* 弹框模式 */}
<TabPane tab="弹框模式" key="2">
<Card title="录音组件(弹框模式)" bordered={false} className="example-card">
<AudioRecorder
maxDuration={60}
onFinish={handleModalRecordFinish}
onCancel={handleModalRecordCancel}
modalMode={true} // 弹框模式
triggerButton="打开录音弹框" // 自定义触发按钮文本
/>
{/* 弹框模式录音结果展示 */}
{modalRecordedFile && (
<div className="recording-result">
<h4></h4>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => playRecording(modalRecordedFile)}
>
</Button>
<p>: {modalRecordedFile}</p>
</div>
)}
</Card>
</TabPane>
{/* 自定义配置模式 */}
<TabPane tab="自定义配置模式" key="3">
<Card title="录音组件(自定义配置)" bordered={false} className="example-card">
<AudioRecorder
maxDuration={30} // 自定义最大录音时长为30秒
onFinish={(filePath) => {
setRecordedFile(filePath);
message.success('自定义配置模式录音完成');
}}
onCancel={() => {
setRecordedFile(null);
message.info('自定义配置模式录音已取消');
}}
modalMode={false}
/>
{/* 自定义配置模式录音结果展示 */}
{recordedFile && (
<div className="recording-result">
<h4></h4>
<Button
type="primary"
icon={<PlayCircleOutlined />}
onClick={() => playRecording(recordedFile)}
>
</Button>
<p>: {recordedFile}</p>
<p>自定义最大录音时长: 30秒</p>
</div>
)}
</Card>
</TabPane>
</Tabs>
</div>
);
};
export default Example;

178
src/pages/AudioRecorder06/README.md

@ -0,0 +1,178 @@
# 录音组件 (AudioRecorder)
一个功能完善、界面美观的前端录音组件,支持长按录音、分段录制、进度可视化等功能,适配移动端和桌面端。
## 功能特性
### 1. 录音交互
- 长按录音按钮开始录音
- 松手自动暂停录音
- 支持移动端触摸事件
### 2. 分段录制
- 支持多次长按-松手的分段录音
- 自动保存每段录音数据
- 以进度条长度分割的方式显示分段
### 3. 进度可视化
- 录音时显示实时进度条
- 同步展示当前录音时长和最大录音时长
- 进度条随录音时长线性增长
### 4. 配置灵活性
- 支持自定义最大录音时长(默认60秒)
- 超出最大时长自动停止录音并提示
### 5. 操作控件功能
- 取消录音:清空所有分段数据
- 完成录音:合并所有分段,返回完整录音文件路径
- 按钮状态随录音状态动态禁用/启用
### 6. 提示消息
- 录音完成、取消、权限拒绝时显示浮动提示
- 提示1秒后自动消失
### 7. 使用模式
- 直接嵌入模式:可直接嵌入页面作为独立组件使用
- 弹框模式:支持通过触发按钮打开弹框
## 技术实现
- **React + TypeScript**:组件化开发,类型安全
- **MediaRecorder API**:浏览器原生录音API
- **Ant Design**:UI组件库,提供按钮、弹框等基础组件
- **Less**:CSS预处理器,实现组件样式
## 安装和使用
### 1. 直接使用组件
```tsx
import React from 'react';
import AudioRecorder from './AudioRecorder';
const App: React.FC = () => {
// 处理录音完成
const handleRecordFinish = (filePath: string) => {
console.log('录音完成,文件路径:', filePath);
// 可以在这里处理录音文件,比如上传到服务器
};
// 处理录音取消
const handleRecordCancel = () => {
console.log('录音已取消');
};
return (
<div>
<AudioRecorder
maxDuration={60} // 最大录音时长,默认60秒
onFinish={handleRecordFinish}
onCancel={handleRecordCancel}
modalMode={false} // 直接嵌入模式
/>
</div>
);
};
export default App;
```
### 2. 弹框模式使用
```tsx
import React from 'react';
import AudioRecorder from './AudioRecorder';
const App: React.FC = () => {
// 处理录音完成
const handleRecordFinish = (filePath: string) => {
console.log('弹框模式录音完成,文件路径:', filePath);
};
// 处理录音取消
const handleRecordCancel = () => {
console.log('弹框模式录音已取消');
};
return (
<div>
<AudioRecorder
maxDuration={60}
onFinish={handleRecordFinish}
onCancel={handleRecordCancel}
modalMode={true} // 弹框模式
triggerButton="开始录音" // 自定义触发按钮文本
/>
</div>
);
};
export default App;
```
## API 文档
### AudioRecorder Props
| 参数名 | 类型 | 默认值 | 描述 |
|--------|------|--------|------|
| maxDuration | number | 60 | 最大录音时长(秒) |
| onFinish | (filePath: string) => void | - | 录音完成回调,返回录音文件路径 |
| onCancel | () => void | - | 取消录音回调 |
| modalMode | boolean | false | 是否为弹框模式 |
| triggerButton | React.ReactNode | "开始录音" | 弹框模式下的触发按钮内容 |
## 组件结构
```
AudioRecorder06/
├── AudioRecorder.tsx # 录音组件核心代码
├── index.less # 组件样式文件
├── Example.tsx # 组件使用示例
└── README.md # 组件文档说明
```
## 浏览器兼容性
- Chrome 66+
- Firefox 65+
- Safari 14+
- Edge 79+
## 注意事项
1. **麦克风权限**:组件需要用户授权使用麦克风,请确保浏览器支持并正确配置麦克风权限。
2. **录音格式**:组件使用 `audio/webm;codecs=opus` 格式录制音频,这是现代浏览器支持的高效音频格式。
3. **临时文件**:录音完成后返回的是临时URL,浏览器会在页面刷新或关闭后释放该URL。如果需要长期保存录音文件,建议将其上传到服务器。
4. **移动端适配**:组件已针对移动端进行适配,支持触摸事件,但在某些移动浏览器中可能存在兼容性问题。
## 二次开发
### 1. 修改样式
组件样式使用Less编写,位于 `index.less` 文件中。您可以根据需要修改样式变量或类名。
### 2. 扩展功能
如果需要扩展组件功能,可以在 `AudioRecorder.tsx` 文件中修改或添加代码。例如:
- 添加录音质量设置
- 添加录音格式选择
- 添加录音波形可视化
- 添加录音上传功能
### 3. 自定义提示消息
组件使用Ant Design的 `message` 组件显示提示消息。如果需要自定义提示消息的样式或行为,可以修改 `showMessage` 方法。
## 测试
组件提供了完整的使用示例,位于 `Example.tsx` 文件中。您可以运行示例代码测试组件的各项功能。
## 许可证
MIT License

158
src/pages/AudioRecorder06/index.less

@ -0,0 +1,158 @@
.audio-recorder-container {
max-width: 600px;
margin: 0 auto;
padding: 24px;
background: #fff;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
h3 {
margin: 0 0 24px 0;
font-size: 18px;
font-weight: 600;
text-align: center;
}
}
.audio-recorder-button {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 120px;
height: 120px;
margin: 0 auto 24px;
background: #1890ff;
border: none;
border-radius: 50%;
color: #fff;
font-size: 16px;
cursor: pointer;
transition: all 0.3s ease;
user-select: none;
&:hover {
background: #40a9ff;
transform: scale(1.05);
}
&:active {
transform: scale(0.95);
}
&.recording {
background: #ff4d4f;
animation: pulse 1.5s infinite;
&:hover {
background: #ff7875;
}
}
.anticon {
font-size: 32px;
margin-bottom: 8px;
}
span {
font-size: 14px;
}
}
@keyframes pulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0.7);
}
70% {
box-shadow: 0 0 0 10px rgba(255, 77, 79, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 77, 79, 0);
}
}
.audio-recorder-progress {
margin-bottom: 24px;
}
.audio-recorder-progress-bar {
position: relative;
height: 8px;
background: #f0f0f0;
border-radius: 4px;
overflow: hidden;
margin-bottom: 8px;
}
.audio-recorder-progress-segment {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #1890ff;
transition: width 0.3s ease;
}
.audio-recorder-progress-current {
position: absolute;
top: 0;
left: 0;
height: 100%;
background: #ff4d4f;
transition: width 0.1s ease;
}
.audio-recorder-duration {
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
color: #666;
span {
margin: 0 4px;
}
span:first-child {
color: #1890ff;
font-weight: 500;
}
}
.audio-recorder-actions {
display: flex;
gap: 16px;
justify-content: center;
.ant-btn {
min-width: 120px;
}
}
/* 移动端适配 */
@media (max-width: 768px) {
.audio-recorder-container {
padding: 16px;
}
.audio-recorder-button {
width: 100px;
height: 100px;
.anticon {
font-size: 28px;
}
span {
font-size: 12px;
}
}
.audio-recorder-actions {
flex-direction: column;
.ant-btn {
width: 100%;
}
}
}
Loading…
Cancel
Save