guoyu/fronted_uniapp/utils/speech-recorder.js

463 lines
14 KiB
JavaScript
Raw Permalink Normal View History

2025-12-07 00:11:06 +08:00
/**
* 语音录音工具类
* 使用 uni-app 原生录音 API
* 支持内网环境录音后上传到服务器进行识别
*/
2025-12-07 08:40:26 +08:00
import config from './config.js'
2025-12-07 00:11:06 +08:00
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<boolean>} 是否有权限
*/
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
})
2025-12-07 00:11:06 +08:00
}
/**
* 初始化录音管理器
*/
async init() {
// 先检查权限
const hasPermission = await this.checkPermission()
if (!hasPermission) {
console.error('[录音] 没有录音权限,初始化失败')
throw new Error('没有录音权限')
}
2025-12-07 00:11:06 +08:00
this.recorderManager = uni.getRecorderManager()
// 录音开始监听
this.recorderManager.onStart(() => {
console.log('[录音] 开始录音')
this.isRecording = true
})
// 录音结束监听
this.recorderManager.onStop((res) => {
2025-12-07 08:40:26 +08:00
console.log('[录音] 录音结束,详细信息:', {
tempFilePath: res.tempFilePath,
duration: res.duration || '未知',
fileSize: res.fileSize || '未知'
})
2025-12-07 00:11:06 +08:00
this.isRecording = false
this.tempFilePath = res.tempFilePath
2025-12-07 08:40:26 +08:00
// 保存录音元数据,用于后续验证
this.lastRecordInfo = {
path: res.tempFilePath,
duration: res.duration,
fileSize: res.fileSize,
timestamp: Date.now()
}
2025-12-07 08:40:26 +08:00
// 验证文件是否存在(异步,不阻塞)
if (res.tempFilePath) {
// 第一次验证(立即)
this.verifyFile(res.tempFilePath, 1)
// 第二次验证500ms后
setTimeout(() => {
this.verifyFile(res.tempFilePath, 2)
}, 500)
// 第三次验证1500ms后最终验证
setTimeout(() => {
this.verifyFile(res.tempFilePath, 3)
}, 1500)
2025-12-07 08:40:26 +08:00
}
2025-12-07 00:11:06 +08:00
})
// 录音错误监听
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, // 最长录音时间(毫秒)
2025-12-07 08:40:26 +08:00
numberOfChannels: 1, // 声道数(单声道)
format: 'wav', // 使用wav格式最兼容百度API官方支持
sampleRate: 8000 // 8000Hz采样率最兼容老设备支持度最高
2025-12-07 00:11:06 +08:00
}
// 工业设备兼容性配置Android 7.1.2等老设备)
// WAV 8000Hz 是最保险的选择,几乎所有设备都支持
console.log('[录音配置] 使用WAV 8000Hz格式最大兼容性')
2025-12-07 00:11:06 +08:00
const config = { ...defaultOptions, ...options }
this.recorderManager.start(config)
}
/**
* 停止录音
* @returns {Promise<string>} 录音文件临时路径
*/
stop() {
return new Promise((resolve, reject) => {
if (!this.isRecording) {
2025-12-07 01:19:40 +08:00
// 如果已经停止了,但有临时文件,返回该文件
if (this.tempFilePath) {
2025-12-07 08:40:26 +08:00
console.log('[录音] 使用已有的录音文件:', this.tempFilePath)
2025-12-07 01:19:40 +08:00
resolve(this.tempFilePath)
} else {
reject(new Error('未在录音中'))
}
2025-12-07 00:11:06 +08:00
return
}
2025-12-07 08:40:26 +08:00
console.log('[录音] 准备停止录音...')
console.log('[录音] 录音状态:', this.isRecording)
2025-12-07 00:11:06 +08:00
2025-12-07 08:40:26 +08:00
// 先停止录音
2025-12-07 00:11:06 +08:00
this.recorderManager.stop()
2025-12-07 08:40:26 +08:00
// 等待1500ms让音频缓冲完全写入MP3编码需要更长时间
console.log('[录音] 等待音频缓冲写入和MP3编码...')
2025-12-07 08:40:26 +08:00
setTimeout(() => {
// 等待录音停止完成
const timeout = setTimeout(() => {
console.error('[录音] 停止录音超时!')
reject(new Error('停止录音超时'))
}, 10000) // 增加超时时间
2025-12-07 08:40:26 +08:00
// 等待onStop事件已在init中注册
const checkInterval = setInterval(() => {
if (!this.isRecording && this.tempFilePath) {
clearTimeout(timeout)
clearInterval(checkInterval)
console.log('[录音] 停止成功,文件路径:', this.tempFilePath)
// 再等待1000ms确保MP3文件完全编码和写入磁盘
console.log('[录音] 等待MP3文件完全编码保存...')
2025-12-07 08:40:26 +08:00
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
2025-12-07 08:40:26 +08:00
}
}, 100)
}, 1500) // 增加到1500ms
2025-12-07 00:11:06 +08:00
})
}
/**
* 上传录音到服务器进行识别
* @param {string} filePath 录音文件路径
* @param {Object} params 附加参数如题目文本等
* @returns {Promise<Object>} 识别结果
*/
uploadAndRecognize(filePath, params = {}) {
return new Promise((resolve, reject) => {
const serverUrl = config.API_BASE_URL
// 从文件路径提取格式
let format = 'pcm' // 默认pcm
if (filePath) {
const ext = filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase()
if (ext) {
format = ext
}
}
2025-12-07 08:40:26 +08:00
console.log('[上传] 开始上传录音文件')
console.log('[上传] 服务器地址:', serverUrl)
console.log('[上传] 文件路径:', filePath)
console.log('[上传] 音频格式:', format)
2025-12-07 08:40:26 +08:00
console.log('[上传] 参数:', params)
const token = uni.getStorageSync('token')
if (!token) {
reject(new Error('请先登录'))
return
}
2025-12-07 00:11:06 +08:00
uni.uploadFile({
url: `${serverUrl}/api/speech/recognize`,
filePath: filePath,
name: 'audio',
formData: {
...params,
format: format, // 动态提取音频格式
2025-12-07 00:11:06 +08:00
sampleRate: 16000
},
header: {
'Authorization': 'Bearer ' + token
},
2025-12-07 00:11:06 +08:00
success: (uploadRes) => {
2025-12-07 08:40:26 +08:00
console.log('[上传] 上传成功,状态码:', uploadRes.statusCode)
console.log('[上传] 响应数据:', uploadRes.data)
2025-12-07 00:11:06 +08:00
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) {
2025-12-07 08:40:26 +08:00
console.error('[上传] 解析响应失败:', e)
2025-12-07 00:11:06 +08:00
reject(new Error('解析结果失败'))
}
} else {
2025-12-07 08:40:26 +08:00
console.error('[上传] HTTP状态码错误:', uploadRes.statusCode)
2025-12-07 00:11:06 +08:00
reject(new Error('上传失败'))
}
},
fail: (err) => {
2025-12-07 08:40:26 +08:00
console.error('[上传] 上传请求失败:', err)
2025-12-07 00:11:06 +08:00
reject(err)
}
})
})
}
/**
* 完整的语音评测流程
* @param {string} referenceText 参考文本标准答案
* @param {number} duration 录音时长毫秒
* @returns {Promise<Object>} 评测结果
*/
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
}
}
}
2025-12-07 01:19:40 +08:00
/**
* 异步评测方法用于已停止录音的场景
* @param {string} referenceText 参考文本
* @param {number} questionId 题目ID可选
* @returns {Promise<Object>} 评测结果
*/
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 || '评测失败'
}
}
}
2025-12-07 00:11:06 +08:00
}
// 导出单例
export default new SpeechRecorder()