406 lines
14 KiB
JavaScript
406 lines
14 KiB
JavaScript
/**
|
||
* 学伴录音功能核心逻辑
|
||
* 模块化组织:录音管理、ASR处理、音频播放、UI控制
|
||
*/
|
||
|
||
// ==================== 全局状态管理 ====================
|
||
const AudioState = {
|
||
recording: {
|
||
mediaRecorder: null,
|
||
audioChunks: [],
|
||
isRecording: false,
|
||
maxDuration: 60000 // 60秒
|
||
},
|
||
playback: {
|
||
audioElement: null,
|
||
isPlaying: false
|
||
}
|
||
};
|
||
|
||
// ==================== 工具函数 ====================
|
||
const Utils = {
|
||
// 获取URL参数
|
||
getUrlParam(name) {
|
||
const reg = new RegExp('(^|&)' + name + '=([^&]*)(&|$)');
|
||
const r = window.location.search.substr(1).match(reg);
|
||
return r ? unescape(r[2]) : null;
|
||
},
|
||
|
||
// 格式化时间显示
|
||
formatTime(seconds) {
|
||
const mins = Math.floor(seconds / 60).toString().padStart(2, '0');
|
||
const secs = Math.floor(seconds % 60).toString().padStart(2, '0');
|
||
return `${mins}:${secs}`;
|
||
}
|
||
};
|
||
|
||
// ==================== UI控制器 ====================
|
||
const UIController = {
|
||
// 显示/隐藏元素
|
||
toggleElement(elementId, show) {
|
||
const element = document.getElementById(elementId);
|
||
if (element) {
|
||
element.style.display = show ? 'flex' : 'none';
|
||
}
|
||
},
|
||
|
||
// 更新按钮状态
|
||
updateRecordingButtons(isRecording) {
|
||
this.toggleElement('recordingIndicator', isRecording);
|
||
this.toggleElement('startRecordBtn', !isRecording);
|
||
this.toggleElement('stopRecordBtn', isRecording);
|
||
},
|
||
|
||
// 禁用/启用帮我讲题按钮
|
||
setStartRecordButtonEnabled(enabled) {
|
||
const startBtn = document.getElementById('startRecordBtn');
|
||
if (startBtn) {
|
||
startBtn.disabled = !enabled;
|
||
startBtn.style.opacity = enabled ? '1' : '0.5';
|
||
startBtn.style.cursor = enabled ? 'pointer' : 'not-allowed';
|
||
}
|
||
},
|
||
|
||
// 更新播放按钮图标
|
||
updatePlayButton(isPlaying) {
|
||
const btn = document.getElementById('playAudioBtn');
|
||
if (!btn) return;
|
||
|
||
const playIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M8 5V19L19 12L8 5Z" fill="white"/></svg>';
|
||
const pauseIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M6 19H10V5H6V19ZM14 19H18V5H14V19Z" fill="white"/></svg>';
|
||
|
||
btn.innerHTML = isPlaying ? pauseIcon : playIcon;
|
||
},
|
||
|
||
// 更新进度条
|
||
updateProgress(progress) {
|
||
const progressBar = document.getElementById('progressBar');
|
||
if (progressBar) {
|
||
progressBar.style.width = `${progress}%`;
|
||
}
|
||
},
|
||
|
||
// 更新时间显示
|
||
updateTimeDisplay(currentTime, duration) {
|
||
const timeDisplay = document.getElementById('audioTime');
|
||
if (timeDisplay) {
|
||
timeDisplay.textContent = `${Utils.formatTime(currentTime)} / ${Utils.formatTime(duration)}`;
|
||
}
|
||
}
|
||
};
|
||
|
||
// ==================== 录音管理模块 ====================
|
||
const RecordingManager = {
|
||
// 初始化录音
|
||
async initRecording() {
|
||
try {
|
||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||
AudioState.recording.mediaRecorder = new MediaRecorder(stream);
|
||
AudioState.recording.audioChunks = [];
|
||
|
||
// 设置录音数据收集回调
|
||
AudioState.recording.mediaRecorder.ondataavailable = (event) => {
|
||
if (event.data.size > 0) {
|
||
AudioState.recording.audioChunks.push(event.data);
|
||
}
|
||
};
|
||
|
||
// 设置录音完成回调
|
||
AudioState.recording.mediaRecorder.onstop = () => {
|
||
const audioBlob = new Blob(AudioState.recording.audioChunks, { type: 'audio/wav' });
|
||
console.log('录音完成,音频数据大小:', audioBlob.size);
|
||
ASRProcessor.processAudio(audioBlob);
|
||
};
|
||
|
||
return true;
|
||
} catch (error) {
|
||
console.error('获取麦克风权限失败:', error);
|
||
alert('请授权麦克风权限以使用录音功能');
|
||
return false;
|
||
}
|
||
},
|
||
|
||
// 开始录音
|
||
async startRecording() {
|
||
if (AudioState.recording.isRecording) return;
|
||
|
||
console.log('尝试开始录音');
|
||
const initialized = await this.initRecording();
|
||
|
||
if (initialized && AudioState.recording.mediaRecorder) {
|
||
AudioState.recording.mediaRecorder.start();
|
||
AudioState.recording.isRecording = true;
|
||
UIController.updateRecordingButtons(true);
|
||
console.log('开始录音成功');
|
||
|
||
// 设置最长录音时间
|
||
setTimeout(() => this.stopRecording(), AudioState.recording.maxDuration);
|
||
}
|
||
},
|
||
|
||
// 停止录音
|
||
stopRecording() {
|
||
if (!AudioState.recording.isRecording || !AudioState.recording.mediaRecorder) return;
|
||
|
||
AudioState.recording.mediaRecorder.stop();
|
||
AudioState.recording.isRecording = false;
|
||
UIController.updateRecordingButtons(false);
|
||
console.log('停止录音');
|
||
|
||
// 停止音频流
|
||
if (AudioState.recording.mediaRecorder.stream) {
|
||
AudioState.recording.mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
||
}
|
||
}
|
||
};
|
||
|
||
// ==================== ASR处理模块 ====================
|
||
const ASRProcessor = {
|
||
// 处理音频数据
|
||
async processAudio(audioBlob) {
|
||
console.log('开始上传音频到服务器');
|
||
UIController.toggleElement('thinkingIndicator', true);
|
||
// 禁用帮我讲题按钮,防止在思考过程中重复点击
|
||
UIController.setStartRecordButtonEnabled(false);
|
||
|
||
// 创建AbortController用于超时控制
|
||
const controller = new AbortController();
|
||
const timeoutId = setTimeout(() => controller.abort(), 120000); // 120秒超时
|
||
|
||
try {
|
||
const formData = new FormData();
|
||
formData.append('file', audioBlob, 'recording.wav');
|
||
|
||
const response = await fetch('/api/xueban/upload-audio', {
|
||
method: 'POST',
|
||
body: formData,
|
||
signal: controller.signal // 添加超时信号
|
||
});
|
||
|
||
// 请求成功,清除超时定时器
|
||
clearTimeout(timeoutId);
|
||
|
||
if (!response.ok) throw new Error('服务器响应错误');
|
||
|
||
const data = await response.json();
|
||
console.log('处理结果:', data);
|
||
UIController.toggleElement('thinkingIndicator', false);
|
||
// 思考结束,重新启用帮我讲题按钮
|
||
UIController.setStartRecordButtonEnabled(true);
|
||
|
||
if (data.success) {
|
||
ResultDisplay.showResults(data.data);
|
||
} else {
|
||
alert('音频处理失败: ' + data.message);
|
||
}
|
||
} catch (error) {
|
||
// 清除超时定时器
|
||
clearTimeout(timeoutId);
|
||
|
||
console.error('上传音频失败:', error);
|
||
UIController.toggleElement('thinkingIndicator', false);
|
||
// 发生错误时也要重新启用按钮
|
||
UIController.setStartRecordButtonEnabled(true);
|
||
|
||
// 判断是否是超时错误
|
||
if (error.name === 'AbortError') {
|
||
alert('请求超时,服务器响应时间过长,请稍后再试');
|
||
} else {
|
||
alert('上传音频失败: ' + error.message);
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
// ==================== 结果显示模块 ====================
|
||
const ResultDisplay = {
|
||
// 显示ASR识别结果和反馈
|
||
showResults(data) {
|
||
const resultContainer = document.getElementById('resultContainer');
|
||
if (resultContainer) {
|
||
resultContainer.style.display = 'flex';
|
||
}
|
||
|
||
// 显示识别文本
|
||
const asrTextElement = document.getElementById('asrResultText');
|
||
if (asrTextElement) {
|
||
asrTextElement.textContent = data.asr_text || '未识别到内容';
|
||
}
|
||
|
||
// 显示反馈文本
|
||
const feedbackTextElement = document.getElementById('feedbackResultText');
|
||
if (feedbackTextElement) {
|
||
feedbackTextElement.textContent = data.feedback_text || '无反馈内容';
|
||
}
|
||
|
||
// 如果有音频URL,初始化音频播放器
|
||
if (data.audio_url) {
|
||
AudioPlayer.initPlayer(data.audio_url);
|
||
}
|
||
}
|
||
};
|
||
|
||
// ==================== 音频播放模块 ====================
|
||
const AudioPlayer = {
|
||
// 初始化音频播放器
|
||
initPlayer(audioUrl) {
|
||
// 停止当前播放的音频
|
||
if (AudioState.playback.audioElement) {
|
||
AudioState.playback.audioElement.pause();
|
||
}
|
||
|
||
// 创建新的音频元素
|
||
AudioState.playback.audioElement = new Audio(audioUrl);
|
||
AudioState.playback.isPlaying = false;
|
||
|
||
// 绑定音频事件
|
||
AudioState.playback.audioElement.onloadedmetadata = () => {
|
||
this.updateTimeDisplay();
|
||
this.play(); // 自动播放
|
||
};
|
||
|
||
AudioState.playback.audioElement.ontimeupdate = () => {
|
||
this.updateProgress();
|
||
this.updateTimeDisplay();
|
||
};
|
||
|
||
AudioState.playback.audioElement.onended = () => {
|
||
AudioState.playback.isPlaying = false;
|
||
UIController.updatePlayButton(false);
|
||
};
|
||
|
||
// 绑定播放按钮点击事件
|
||
const playBtn = document.getElementById('playAudioBtn');
|
||
if (playBtn) {
|
||
playBtn.onclick = () => this.togglePlay();
|
||
}
|
||
|
||
// 绑定进度条点击事件
|
||
const progressContainer = document.getElementById('audioProgress');
|
||
if (progressContainer) {
|
||
progressContainer.onclick = (e) => {
|
||
const rect = progressContainer.getBoundingClientRect();
|
||
const clickPosition = (e.clientX - rect.left) / rect.width;
|
||
AudioState.playback.audioElement.currentTime = clickPosition * AudioState.playback.audioElement.duration;
|
||
};
|
||
}
|
||
},
|
||
|
||
// 播放/暂停切换
|
||
togglePlay() {
|
||
if (!AudioState.playback.audioElement) return;
|
||
|
||
if (AudioState.playback.isPlaying) {
|
||
this.pause();
|
||
} else {
|
||
this.play();
|
||
}
|
||
},
|
||
|
||
// 播放
|
||
play() {
|
||
if (!AudioState.playback.audioElement) return;
|
||
|
||
try {
|
||
AudioState.playback.audioElement.play();
|
||
AudioState.playback.isPlaying = true;
|
||
UIController.updatePlayButton(true);
|
||
} catch (e) {
|
||
console.error('播放失败:', e);
|
||
}
|
||
},
|
||
|
||
// 暂停
|
||
pause() {
|
||
if (!AudioState.playback.audioElement) return;
|
||
|
||
AudioState.playback.audioElement.pause();
|
||
AudioState.playback.isPlaying = false;
|
||
UIController.updatePlayButton(false);
|
||
},
|
||
|
||
// 更新进度条
|
||
updateProgress() {
|
||
if (!AudioState.playback.audioElement) return;
|
||
|
||
const progress = (AudioState.playback.audioElement.currentTime / AudioState.playback.audioElement.duration) * 100;
|
||
UIController.updateProgress(progress);
|
||
},
|
||
|
||
// 更新时间显示
|
||
updateTimeDisplay() {
|
||
if (!AudioState.playback.audioElement) return;
|
||
|
||
const currentTime = AudioState.playback.audioElement.currentTime;
|
||
const duration = AudioState.playback.audioElement.duration;
|
||
UIController.updateTimeDisplay(currentTime, duration);
|
||
}
|
||
};
|
||
|
||
// ==================== 事件绑定 ====================
|
||
const EventBinder = {
|
||
// 绑定所有事件
|
||
bindEvents() {
|
||
// 绑定录音按钮事件
|
||
const startBtn = document.getElementById('startRecordBtn');
|
||
const stopBtn = document.getElementById('stopRecordBtn');
|
||
|
||
console.log('开始绑定事件,查找按钮元素...');
|
||
console.log('开始录音按钮:', startBtn);
|
||
console.log('停止录音按钮:', stopBtn);
|
||
|
||
if (startBtn) {
|
||
startBtn.onclick = () => {
|
||
console.log('点击开始录音按钮');
|
||
RecordingManager.startRecording();
|
||
};
|
||
console.log('已绑定开始录音按钮事件');
|
||
} else {
|
||
console.error('未找到开始录音按钮');
|
||
}
|
||
|
||
if (stopBtn) {
|
||
stopBtn.onclick = () => {
|
||
console.log('点击停止录音按钮');
|
||
RecordingManager.stopRecording();
|
||
};
|
||
console.log('已绑定停止录音按钮事件');
|
||
} else {
|
||
console.error('未找到停止录音按钮');
|
||
}
|
||
}
|
||
};
|
||
|
||
// ==================== 初始化 ====================
|
||
// 页面加载完成后初始化
|
||
function initializeApp() {
|
||
console.log('开始初始化学伴录音功能...');
|
||
|
||
// 检查DOM是否已就绪
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
EventBinder.bindEvents();
|
||
console.log('学伴录音功能初始化完成(DOMContentLoaded)');
|
||
});
|
||
} else {
|
||
// DOM已经加载完成,直接绑定事件
|
||
EventBinder.bindEvents();
|
||
console.log('学伴录音功能初始化完成(直接执行)');
|
||
}
|
||
}
|
||
|
||
// 立即执行初始化
|
||
initializeApp();
|
||
|
||
// 同时保留原有的DOMContentLoaded事件作为备用
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
EventBinder.bindEvents();
|
||
console.log('学伴录音功能备用初始化完成');
|
||
});
|
||
|
||
// 页面加载完成后也尝试绑定(确保万无一失)
|
||
window.addEventListener('load', () => {
|
||
EventBinder.bindEvents();
|
||
console.log('学伴录音功能load事件初始化完成');
|
||
});
|