/** * 语音录音工具类 * 使用 uni-app 原生录音 API * 支持内网环境,录音后上传到服务器进行识别 */ import config from './config.js' class SpeechRecorder { constructor() { this.recorderManager = null this.isRecording = false this.tempFilePath = '' this.lastRecordInfo = null // 保存最后一次录音信息 this.fileVerifyHistory = [] // 文件验证历史 } /** * 创建备份到永久目录(防止临时文件被系统清理) * @param {string} tempPath 临时文件路径 * @param {number} fileSize 文件大小 */ createBackup(tempPath, fileSize) { try { // #ifdef APP-PLUS // 在APP中保存到本地存储目录 const fs = plus.io.requestFileSystem(plus.io.PUBLIC_DOCUMENTS, (fs) => { const fileName = 'voice_backup_' + Date.now() + '.mp3' fs.root.getDirectory('recordings', { create: true }, (dirEntry) => { // 复制文件 plus.io.resolveLocalFileSystemURL(tempPath, (entry) => { entry.copyTo(dirEntry, fileName, (newEntry) => { console.log('[录音] 备份成功:', newEntry.fullPath, '大小:', fileSize) // 保存备份路径,可用于恢复 this.lastBackupPath = newEntry.fullPath }, (err) => { console.warn('[录音] 备份失败(不影响使用):', err.message) } ) }) }) }) // #endif // #ifndef APP-PLUS console.log('[录音] 非APP环境,跳过备份') // #endif } catch (err) { console.warn('[录音] 创建备份异常(不影响使用):', err) } } /** * 验证文件是否完整 * @param {string} filePath 文件路径 * @param {number} attemptNo 验证次数 */ verifyFile(filePath, attemptNo = 1) { uni.getFileInfo({ filePath: filePath, success: (fileInfo) => { const verifyInfo = { attempt: attemptNo, time: Date.now(), size: fileInfo.size, path: filePath } this.fileVerifyHistory.push(verifyInfo) console.log(`[录音] 第${attemptNo}次验证成功:`, { size: fileInfo.size, sizeKB: (fileInfo.size / 1024).toFixed(2) + 'KB', digest: fileInfo.digest || '未知' }) // 检查文件大小增长(用于判断是否还在写入) if (attemptNo > 1 && this.fileVerifyHistory.length >= 2) { const prevSize = this.fileVerifyHistory[this.fileVerifyHistory.length - 2].size const growth = fileInfo.size - prevSize if (growth > 0) { console.log(`[录音] 文件仍在增长: +${growth} bytes`) } else { console.log('[录音] 文件大小稳定,编码完成') } } // 最终验证:检查文件是否太小 if (attemptNo === 3) { const expectedSize = this.lastRecordInfo?.duration ? this.lastRecordInfo.duration * 16 : 45000 if (fileInfo.size < expectedSize * 0.3) { console.error('[录音] 警告:文件过小!实际=' + fileInfo.size + ', 预期>' + expectedSize) } } }, fail: (err) => { console.error(`[录音] 第${attemptNo}次验证失败:`, err) this.fileVerifyHistory.push({ attempt: attemptNo, time: Date.now(), error: err.errMsg }) } }) } /** * 检查录音权限 * @returns {Promise} 是否有权限 */ async checkPermission() { return new Promise((resolve) => { // #ifdef APP-PLUS const permissions = ['android.permission.RECORD_AUDIO'] // 检查权限 const hasPermission = plus.android.checkPermission(permissions[0]) if (hasPermission === 0) { // 没有权限,请求权限 console.log('[录音] 请求录音权限...') plus.android.requestPermissions( permissions, (result) => { const granted = result.granted && result.granted.length > 0 console.log('[录音] 权限请求结果:', granted ? '已授权' : '被拒绝') if (!granted) { uni.showModal({ title: '需要录音权限', content: '请在设置中开启录音权限,否则无法使用语音功能', showCancel: false }) } resolve(granted) }, (error) => { console.error('[录音] 权限请求失败', error) resolve(false) } ) } else { console.log('[录音] 已有录音权限') resolve(true) } // #endif // #ifndef APP-PLUS // 非APP环境默认有权限 resolve(true) // #endif }) } /** * 初始化录音管理器 */ async init() { // 先检查权限 const hasPermission = await this.checkPermission() if (!hasPermission) { console.error('[录音] 没有录音权限,初始化失败') throw new Error('没有录音权限') } this.recorderManager = uni.getRecorderManager() // 录音开始监听 this.recorderManager.onStart(() => { console.log('[录音] 开始录音') this.isRecording = true }) // 录音结束监听 this.recorderManager.onStop((res) => { console.log('[录音] 录音结束,详细信息:', { tempFilePath: res.tempFilePath, duration: res.duration || '未知', fileSize: res.fileSize || '未知' }) this.isRecording = false this.tempFilePath = res.tempFilePath // 保存录音元数据,用于后续验证 this.lastRecordInfo = { path: res.tempFilePath, duration: res.duration, fileSize: res.fileSize, timestamp: Date.now() } // 验证文件是否存在(异步,不阻塞) if (res.tempFilePath) { // 第一次验证(立即) this.verifyFile(res.tempFilePath, 1) // 第二次验证(500ms后) setTimeout(() => { this.verifyFile(res.tempFilePath, 2) }, 500) // 第三次验证(1500ms后,最终验证) setTimeout(() => { this.verifyFile(res.tempFilePath, 3) }, 1500) } }) // 录音错误监听 this.recorderManager.onError((err) => { console.error('[录音] 录音错误', err) this.isRecording = false uni.showToast({ title: '录音失败:' + err.errMsg, icon: 'none' }) }) } /** * 开始录音 * @param {Object} options 录音配置 */ start(options = {}) { if (!this.recorderManager) { this.init() } const defaultOptions = { duration: 60000, // 最长录音时间(毫秒) sampleRate: 16000, // 采样率(百度API推荐) numberOfChannels: 1, // 声道数(单声道) encodeBitRate: 128000, // 编码码率(提高到128k,减少数据丢失) format: 'mp3', // 音频格式(统一使用MP3,兼容性最好) frameSize: 10 // 帧大小(值越小越频繁回调,数据越完整,默认50可能太大) } const config = { ...defaultOptions, ...options } this.recorderManager.start(config) } /** * 停止录音 * @returns {Promise} 录音文件临时路径 */ stop() { return new Promise((resolve, reject) => { if (!this.isRecording) { // 如果已经停止了,但有临时文件,返回该文件 if (this.tempFilePath) { console.log('[录音] 使用已有的录音文件:', this.tempFilePath) resolve(this.tempFilePath) } else { reject(new Error('未在录音中')) } return } console.log('[录音] 准备停止录音...') console.log('[录音] 录音状态:', this.isRecording) // 先停止录音 this.recorderManager.stop() // 等待1500ms让音频缓冲完全写入(MP3编码需要更长时间) console.log('[录音] 等待音频缓冲写入和MP3编码...') setTimeout(() => { // 等待录音停止完成 const timeout = setTimeout(() => { console.error('[录音] 停止录音超时!') reject(new Error('停止录音超时')) }, 10000) // 增加超时时间 // 等待onStop事件(已在init中注册) const checkInterval = setInterval(() => { if (!this.isRecording && this.tempFilePath) { clearTimeout(timeout) clearInterval(checkInterval) console.log('[录音] 停止成功,文件路径:', this.tempFilePath) // 再等待1000ms确保MP3文件完全编码和写入磁盘 console.log('[录音] 等待MP3文件完全编码保存...') setTimeout(() => { // 验证文件并创建备份 uni.getFileInfo({ filePath: this.tempFilePath, success: (fileInfo) => { console.log('[录音] 文件已完全保存,大小:', fileInfo.size, 'bytes') // 创建备份到永久目录(可选,降低丢失风险) this.createBackup(this.tempFilePath, fileInfo.size) if (fileInfo.size < 5000) { console.warn('[录音] 警告:文件太小,可能数据丢失') } // 添加文件元数据 const fileWithMeta = { path: this.tempFilePath, size: fileInfo.size, duration: this.lastRecordInfo?.duration, verifyCount: this.fileVerifyHistory.length, timestamp: Date.now() } resolve(this.tempFilePath) }, fail: (err) => { console.error('[录音] 文件验证失败,但仍返回路径', err) resolve(this.tempFilePath) } }) }, 1000) // 增加到1000ms } }, 100) }, 1500) // 增加到1500ms }) } /** * 上传录音到服务器进行识别 * @param {string} filePath 录音文件路径 * @param {Object} params 附加参数(如题目文本等) * @returns {Promise} 识别结果 */ uploadAndRecognize(filePath, params = {}) { return new Promise((resolve, reject) => { // 使用导入的服务器配置 // 开发环境可以用 localhost:5000 测试 // const serverUrl = 'http://localhost:5000' // Windows本地测试 const serverUrl = config.API_BASE_URL console.log('[上传] 开始上传录音文件') console.log('[上传] 服务器地址:', serverUrl) console.log('[上传] 文件路径:', filePath) console.log('[上传] 参数:', params) uni.uploadFile({ url: `${serverUrl}/api/speech/recognize`, filePath: filePath, name: 'audio', formData: { ...params, format: 'mp3', // 匹配录音格式 sampleRate: 16000 }, success: (uploadRes) => { console.log('[上传] 上传成功,状态码:', uploadRes.statusCode) console.log('[上传] 响应数据:', uploadRes.data) if (uploadRes.statusCode === 200) { try { const result = JSON.parse(uploadRes.data) if (result.code === 200) { resolve(result.data) } else { reject(new Error(result.msg || '识别失败')) } } catch (e) { console.error('[上传] 解析响应失败:', e) reject(new Error('解析结果失败')) } } else { console.error('[上传] HTTP状态码错误:', uploadRes.statusCode) reject(new Error('上传失败')) } }, fail: (err) => { console.error('[上传] 上传请求失败:', err) reject(err) } }) }) } /** * 完整的语音评测流程 * @param {string} referenceText 参考文本(标准答案) * @param {number} duration 录音时长(毫秒) * @returns {Promise} 评测结果 */ async evaluate(referenceText, duration = 10000) { try { // 1. 开始录音 this.start({ duration }) // 2. 等待录音完成(用户手动停止或超时) const filePath = await this.stop() // 3. 上传并识别 const result = await this.uploadAndRecognize(filePath, { referenceText: referenceText }) return { success: true, ...result } } catch (error) { console.error('[语音评测] 失败', error) return { success: false, error: error.message } } } /** * 异步评测方法(用于已停止录音的场景) * @param {string} referenceText 参考文本 * @param {number} questionId 题目ID(可选) * @returns {Promise} 评测结果 */ async evaluateAsync(referenceText, questionId = null) { try { console.log('[语音评测] 开始异步评测', { referenceText, questionId, tempFilePath: this.tempFilePath }) // 检查是否有录音文件 if (!this.tempFilePath) { throw new Error('没有可用的录音文件') } // 上传并识别 const result = await this.uploadAndRecognize(this.tempFilePath, { referenceText: referenceText, questionId: questionId }) console.log('[语音评测] 评测成功', result) return { success: true, ...result } } catch (error) { console.error('[语音评测] 评测失败', error) return { success: false, error: error.message || '评测失败' } } } } // 导出单例 export default new SpeechRecorder()