/**
* 音频录制控制器(ESM)
* 封装 MediaRecorder API 和相关 UI 逻辑
*/
export class AudioRecorder {
constructor(options = {}) {
this.options = Object.assign({
onMessage: (msg, type) => console.log(msg, type),
onLoading: (show, msg) => {}
}, options);
this.config = {
MAX_DURATION: 600, // 10分钟
MIN_DURATION: 1,
AUDIO_TYPE: 'audio/webm;codecs=opus',
FALLBACK_TYPE: 'audio/wav',
UPDATE_INTERVAL: 100
};
this.media = {
mediaRecorder: null,
audioChunks: [],
audioUrl: null,
audioBlob: null,
stream: null,
audioElement: null
};
this.state = {
isRecording: false,
isPaused: false,
isPlaying: false,
duration: 0,
currentTime: 0,
startTime: null,
recordingTimer: null,
playbackTimer: null,
hasRecording: false
};
this.selectors = {
startBtn: '#startRecording',
stopBtn: '#stopRecording',
playBtn: '#playRecording',
pauseBtn: '#pauseRecording',
clearBtn: '#clearRecording',
uploadBtn: '#uploadRecording',
recordingSection: '.recording-section',
playerSection: '#recordingPlayer',
statusText: '#recordingStatusText',
recordingTime: '#recordingTime',
currentTime: '#currentTime',
totalTime: '#totalTime',
progressBar: '#audioProgressBar',
progressHandle: '#audioProgressHandle',
progressContainer: '#audioProgress'
};
}
init() {
this.bindEvents();
this.resetState();
}
bindEvents() {
$(this.selectors.startBtn).on('click', () => this.startRecording());
$(this.selectors.stopBtn).on('click', () => this.stopRecording());
$(this.selectors.playBtn).on('click', () => this.togglePlayback());
$(this.selectors.pauseBtn).on('click', () => this.pausePlayback());
$(this.selectors.clearBtn).on('click', () => this.clearRecording());
$(this.selectors.uploadBtn).on('click', () => this.uploadRecording());
// 进度条拖动
this.bindProgressBarEvents();
}
resetState() {
this.stopRecording();
this.stopPlayback();
this.cleanupMediaResources();
this.state = {
isRecording: false,
isPaused: false,
isPlaying: false,
duration: 0,
currentTime: 0,
startTime: null,
recordingTimer: null,
playbackTimer: null,
hasRecording: false
};
this.updateUI();
}
cleanupMediaResources() {
if (this.media.stream) {
this.media.stream.getTracks().forEach(track => track.stop());
this.media.stream = null;
}
if (this.media.audioUrl) {
URL.revokeObjectURL(this.media.audioUrl);
this.media.audioUrl = null;
}
this.media.mediaRecorder = null;
this.media.audioChunks = [];
this.media.audioBlob = null;
this.media.audioElement = null;
}
async startRecording() {
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
this.options.onMessage('浏览器不支持录音功能', 'error');
return;
}
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
this.media.stream = stream;
const mimeType = MediaRecorder.isTypeSupported(this.config.AUDIO_TYPE)
? this.config.AUDIO_TYPE
: this.config.FALLBACK_TYPE;
this.media.mediaRecorder = new MediaRecorder(stream, { mimeType });
this.media.audioChunks = [];
this.media.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.media.audioChunks.push(event.data);
}
};
this.media.mediaRecorder.onstop = () => {
this.processRecordedAudio(mimeType);
};
this.media.mediaRecorder.start();
this.state.isRecording = true;
this.state.startTime = Date.now();
this.startRecordingTimer();
this.updateUI();
} catch (error) {
console.error('录音启动失败:', error);
this.options.onMessage('无法启动录音,请检查麦克风权限', 'error');
}
}
stopRecording() {
if (this.media.mediaRecorder && this.state.isRecording) {
this.media.mediaRecorder.stop();
this.state.isRecording = false;
this.stopRecordingTimer();
// stream cleanup happens in cleanupMediaResources or after processing?
// Usually we keep stream until we clear or restart?
// Better to stop tracks now to release mic.
if (this.media.stream) {
this.media.stream.getTracks().forEach(track => track.stop());
this.media.stream = null;
}
}
}
startRecordingTimer() {
this.stopRecordingTimer();
this.state.recordingTimer = setInterval(() => {
const duration = (Date.now() - this.state.startTime) / 1000;
this.state.duration = duration;
this.updateTimeDisplay(duration, this.selectors.recordingTime);
if (duration >= this.config.MAX_DURATION) {
this.stopRecording();
this.options.onMessage('已达到最大录音时长', 'info');
}
}, this.config.UPDATE_INTERVAL);
}
stopRecordingTimer() {
if (this.state.recordingTimer) {
clearInterval(this.state.recordingTimer);
this.state.recordingTimer = null;
}
}
processRecordedAudio(mimeType) {
this.media.audioBlob = new Blob(this.media.audioChunks, { type: mimeType });
this.media.audioUrl = URL.createObjectURL(this.media.audioBlob);
this.media.audioElement = new Audio(this.media.audioUrl);
this.media.audioElement.onended = () => {
this.state.isPlaying = false;
this.stopPlaybackTimer();
this.state.currentTime = 0;
this.updateUI();
};
this.media.audioElement.onloadedmetadata = () => {
if (this.media.audioElement.duration && !isNaN(this.media.audioElement.duration)) {
// fix duration if needed, but we use recorded duration
}
};
this.state.hasRecording = true;
this.updateUI();
}
togglePlayback() {
if (!this.media.audioElement) return;
if (this.state.isPlaying) {
this.pausePlayback();
} else {
this.startPlayback();
}
}
startPlayback() {
if (!this.media.audioElement) return;
this.media.audioElement.play();
this.state.isPlaying = true;
this.startPlaybackTimer();
this.updateUI();
}
pausePlayback() {
if (!this.media.audioElement) return;
this.media.audioElement.pause();
this.state.isPlaying = false;
this.stopPlaybackTimer();
this.updateUI();
}
stopPlayback() {
if (!this.media.audioElement) return;
this.media.audioElement.pause();
this.media.audioElement.currentTime = 0;
this.state.isPlaying = false;
this.stopPlaybackTimer();
this.state.currentTime = 0;
this.updateUI();
}
startPlaybackTimer() {
this.stopPlaybackTimer();
this.state.playbackTimer = setInterval(() => {
if (this.media.audioElement) {
this.state.currentTime = this.media.audioElement.currentTime;
this.updateTimeDisplay(this.state.currentTime, this.selectors.currentTime);
this.updatePlaybackProgress();
}
}, this.config.UPDATE_INTERVAL);
}
stopPlaybackTimer() {
if (this.state.playbackTimer) {
clearInterval(this.state.playbackTimer);
this.state.playbackTimer = null;
}
}
clearRecording() {
if (confirm('确定要清除当前录音吗?')) {
this.resetState();
}
}
async uploadRecording() {
if (!this.media.audioBlob) {
this.options.onMessage('没有可上传的录音', 'warning');
return;
}
// 确定录音文件扩展名
let extension = 'webm';
if (this.media.audioBlob.type.includes('wav')) {
extension = 'wav';
} else if (this.media.audioBlob.type.includes('mp3')) {
extension = 'mp3';
} else if (this.media.audioBlob.type.includes('flac')) {
extension = 'flac';
}
const formData = new FormData();
formData.append('record', this.media.audioBlob, `recording.${extension}`);
formData.append('duration', this.state.duration.toString());
try {
// 使用外部回调处理加载状态
this.options.onLoading(true, '正在上传...');
const result = await this.uploadWithProgress(
'/Audio/Upload',
formData,
(percent) => {
this.options.onLoading(true, `正在上传录音...${percent}%`);
}
);
this.options.onLoading(false);
if (!result.success) {
this.options.onMessage(result.msg || '上传失败', 'error');
return;
}
this.options.onMessage('录音上传成功', 'success');
// 插入音频标签到编辑器
if (result.data && result.data.accessUrl) {
this.insertAudioToEditor(result.data.accessUrl);
}
// 清除录音状态
this.resetState();
} catch (error) {
this.options.onLoading(false);
this.options.onMessage('上传失败: ' + error.message, 'error');
console.error('上传录音失败:', error);
}
}
updateUI() {
const { isRecording, hasRecording, isPlaying, duration, currentTime } = this.state;
// 按钮状态
$(this.selectors.startBtn).toggle(!isRecording && !hasRecording);
$(this.selectors.stopBtn).toggle(isRecording);
$(this.selectors.playBtn).toggle(hasRecording && !isPlaying);
$(this.selectors.pauseBtn).toggle(hasRecording && isPlaying);
$(this.selectors.clearBtn).toggle(hasRecording && !isRecording);
$(this.selectors.uploadBtn).toggle(hasRecording && !isRecording);
// 区域显示
if (hasRecording) {
$(this.selectors.playerSection).slideDown();
} else {
$(this.selectors.playerSection).slideUp();
}
// 状态文本
if (isRecording) {
$(this.selectors.statusText).text('正在录音...');
$(this.selectors.recordingSection).addClass('recording');
} else {
$(this.selectors.statusText).text(hasRecording ? '录音完成' : '准备录音');
$(this.selectors.recordingSection).removeClass('recording');
}
// 时间显示
this.updateTimeDisplay(duration, this.selectors.totalTime);
this.updateTimeDisplay(currentTime, this.selectors.currentTime);
this.updatePlaybackProgress();
}
updateTimeDisplay(seconds, selector) {
const min = Math.floor(seconds / 60);
const sec = Math.floor(seconds % 60);
$(selector).text(`${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`);
}
updatePlaybackProgress() {
if (this.state.duration > 0) {
const percent = (this.state.currentTime / this.state.duration) * 100;
$(this.selectors.progressBar).css('width', `${percent}%`);
$(this.selectors.progressHandle).css('left', `${percent}%`);
} else {
$(this.selectors.progressBar).css('width', '0%');
$(this.selectors.progressHandle).css('left', '0%');
}
}
bindProgressBarEvents() {
let isDragging = false;
const container = $(this.selectors.progressContainer);
container.on('mousedown touchstart', (e) => {
if (!this.state.hasRecording) return;
isDragging = true;
this.handleSeek(e);
});
$(document).on('mousemove touchmove', (e) => {
if (isDragging) {
e.preventDefault();
this.handleSeek(e);
}
});
$(document).on('mouseup touchend', () => {
isDragging = false;
});
}
handleSeek(e) {
const container = $(this.selectors.progressContainer);
const offset = container.offset();
const width = container.width();
let clientX = e.clientX;
if (e.type.includes('touch')) {
clientX = e.touches[0].clientX;
}
let percent = (clientX - offset.left) / width;
percent = Math.max(0, Math.min(1, percent));
const time = percent * this.state.duration;
this.seekTo(time);
}
seekTo(time) {
if (!this.media.audioElement) return;
this.media.audioElement.currentTime = time;
this.state.currentTime = time;
this.updateTimeDisplay(time, this.selectors.currentTime);
this.updatePlaybackProgress();
}
uploadWithProgress(url, formData, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', url, true);
// 监听上传进度
if (xhr.upload && onProgress) {
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
const percent = Math.round((e.loaded / e.total) * 100);
onProgress(percent);
}
};
}
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
resolve(response);
} catch (e) {
reject(new Error('Invalid JSON response'));
}
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
};
xhr.onerror = () => reject(new Error('Network error'));
xhr.send(formData);
});
}
insertAudioToEditor(audioUrl) {
try {
// 构建音频标签的 HTML
const audioHtml = `\n<audio controls src="${audioUrl}" style="max-width: 100%; margin: 10px 0;"></audio>\n`;
const editorEl = document.getElementById('mumbleContent') ||
document.getElementById('articleContent') ||
document.getElementById('timelineContent');
if (editorEl) {
const currentContent = editorEl.value || '';
editorEl.value = currentContent + audioHtml;
}
} catch (error) {
console.error('插入音频标签失败:', error);
}
}
}
评论加载中...