2025-08-28 15:44:34 +08:00
|
|
|
|
/**
|
|
|
|
|
* 学伴录音功能核心逻辑
|
|
|
|
|
* 模块化组织:录音管理、ASR处理、音频播放、UI控制
|
|
|
|
|
*/
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== 全局状态管理 ====================
|
|
|
|
|
const AudioState = {
|
|
|
|
|
recording: {
|
|
|
|
|
mediaRecorder: null,
|
|
|
|
|
audioChunks: [],
|
|
|
|
|
isRecording: false,
|
|
|
|
|
maxDuration: 60000 // 60秒
|
|
|
|
|
},
|
|
|
|
|
playback: {
|
|
|
|
|
audioElement: null,
|
|
|
|
|
isPlaying: false
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== 工具函数 ====================
|
|
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== 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);
|
|
|
|
|
},
|
|
|
|
|
|
|
|
|
|
// 更新播放按钮图标
|
|
|
|
|
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)}`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== 录音管理模块 ====================
|
|
|
|
|
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);
|
|
|
|
|
}
|
2025-08-28 15:35:54 +08:00
|
|
|
|
};
|
2025-08-28 15:44:34 +08:00
|
|
|
|
|
|
|
|
|
// 设置录音完成回调
|
|
|
|
|
AudioState.recording.mediaRecorder.onstop = () => {
|
|
|
|
|
const audioBlob = new Blob(AudioState.recording.audioChunks, { type: 'audio/wav' });
|
|
|
|
|
console.log('录音完成,音频数据大小:', audioBlob.size);
|
|
|
|
|
ASRProcessor.processAudio(audioBlob);
|
2025-08-28 15:35:54 +08:00
|
|
|
|
};
|
2025-08-28 15:44:34 +08:00
|
|
|
|
|
|
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== ASR处理模块 ====================
|
|
|
|
|
const ASRProcessor = {
|
|
|
|
|
// 处理音频数据
|
|
|
|
|
async processAudio(audioBlob) {
|
|
|
|
|
console.log('开始上传音频到服务器');
|
|
|
|
|
UIController.toggleElement('thinkingIndicator', true);
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const formData = new FormData();
|
|
|
|
|
formData.append('file', audioBlob, 'recording.wav');
|
|
|
|
|
|
|
|
|
|
const response = await fetch('/api/xueban/upload-audio', {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: formData
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!response.ok) throw new Error('服务器响应错误');
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
|
|
|
|
console.log('处理结果:', data);
|
|
|
|
|
UIController.toggleElement('thinkingIndicator', false);
|
|
|
|
|
|
|
|
|
|
if (data.success) {
|
|
|
|
|
ResultDisplay.showResults(data.data);
|
|
|
|
|
} else {
|
|
|
|
|
alert('音频处理失败: ' + data.message);
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.error('上传音频失败:', error);
|
|
|
|
|
UIController.toggleElement('thinkingIndicator', false);
|
|
|
|
|
alert('上传音频失败: ' + error.message);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== 结果显示模块 ====================
|
|
|
|
|
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);
|
|
|
|
|
}
|
2025-08-28 15:35:54 +08:00
|
|
|
|
}
|
2025-08-28 15:44:34 +08:00
|
|
|
|
};
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== 音频播放模块 ====================
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// 播放/暂停切换
|
|
|
|
|
togglePlay() {
|
|
|
|
|
if (!AudioState.playback.audioElement) return;
|
|
|
|
|
|
|
|
|
|
if (AudioState.playback.isPlaying) {
|
|
|
|
|
this.pause();
|
2025-08-28 15:35:54 +08:00
|
|
|
|
} else {
|
2025-08-28 15:44:34 +08:00
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== 事件绑定 ====================
|
|
|
|
|
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('未找到停止录音按钮');
|
|
|
|
|
}
|
2025-08-28 15:35:54 +08:00
|
|
|
|
}
|
2025-08-28 15:44:34 +08:00
|
|
|
|
};
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// ==================== 初始化 ====================
|
|
|
|
|
// 页面加载完成后初始化
|
|
|
|
|
function initializeApp() {
|
|
|
|
|
console.log('开始初始化学伴录音功能...');
|
|
|
|
|
|
|
|
|
|
// 检查DOM是否已就绪
|
|
|
|
|
if (document.readyState === 'loading') {
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
EventBinder.bindEvents();
|
|
|
|
|
console.log('学伴录音功能初始化完成(DOMContentLoaded)');
|
|
|
|
|
});
|
|
|
|
|
} else {
|
|
|
|
|
// DOM已经加载完成,直接绑定事件
|
|
|
|
|
EventBinder.bindEvents();
|
|
|
|
|
console.log('学伴录音功能初始化完成(直接执行)');
|
|
|
|
|
}
|
2025-08-28 15:35:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// 立即执行初始化
|
|
|
|
|
initializeApp();
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// 同时保留原有的DOMContentLoaded事件作为备用
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
|
|
|
EventBinder.bindEvents();
|
|
|
|
|
console.log('学伴录音功能备用初始化完成');
|
|
|
|
|
});
|
2025-08-28 15:35:54 +08:00
|
|
|
|
|
2025-08-28 15:44:34 +08:00
|
|
|
|
// 页面加载完成后也尝试绑定(确保万无一失)
|
|
|
|
|
window.addEventListener('load', () => {
|
|
|
|
|
EventBinder.bindEvents();
|
|
|
|
|
console.log('学伴录音功能load事件初始化完成');
|
|
|
|
|
});
|