guoyu/fronted_uniapp/utils/speech-recorder.js

445 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 语音录音工具类
* 使用 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<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
})
}
/**
* 初始化录音管理器
*/
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<string>} 录音文件临时路径
*/
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<Object>} 识别结果
*/
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<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
}
}
}
/**
* 异步评测方法(用于已停止录音的场景)
* @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 || '评测失败'
}
}
}
}
// 导出单例
export default new SpeechRecorder()