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 = ''
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 初始化录音管理器
|
|
|
|
|
|
*/
|
|
|
|
|
|
init() {
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
// 验证文件是否存在(异步,不阻塞)
|
|
|
|
|
|
if (res.tempFilePath) {
|
|
|
|
|
|
uni.getFileInfo({
|
|
|
|
|
|
filePath: res.tempFilePath,
|
|
|
|
|
|
success: (fileInfo) => {
|
|
|
|
|
|
console.log('[录音] 文件验证成功,大小:', fileInfo.size, 'bytes')
|
|
|
|
|
|
},
|
|
|
|
|
|
fail: (err) => {
|
|
|
|
|
|
console.error('[录音] 文件验证失败:', err)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
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
|
|
|
|
sampleRate: 16000, // 采样率(百度API推荐)
|
|
|
|
|
|
numberOfChannels: 1, // 声道数(单声道)
|
|
|
|
|
|
encodeBitRate: 48000, // 编码码率
|
|
|
|
|
|
format: 'wav', // 音频格式(WAV格式,百度API完美支持)
|
|
|
|
|
|
frameSize: 50 // 指定帧大小,增加缓冲(避免数据丢失)
|
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
|
|
|
|
|
|
|
|
|
|
// 等待800ms让音频缓冲完全写入(增加延迟,解决数据丢失)
|
|
|
|
|
|
console.log('[录音] 等待音频缓冲写入...')
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
// 等待录音停止完成
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
|
console.error('[录音] 停止录音超时!')
|
|
|
|
|
|
reject(new Error('停止录音超时'))
|
|
|
|
|
|
}, 8000)
|
|
|
|
|
|
|
|
|
|
|
|
// 等待onStop事件(已在init中注册)
|
|
|
|
|
|
const checkInterval = setInterval(() => {
|
|
|
|
|
|
if (!this.isRecording && this.tempFilePath) {
|
|
|
|
|
|
clearTimeout(timeout)
|
|
|
|
|
|
clearInterval(checkInterval)
|
|
|
|
|
|
console.log('[录音] 停止成功,文件路径:', this.tempFilePath)
|
|
|
|
|
|
|
|
|
|
|
|
// 再等待500ms确保文件完全写入磁盘(增加延迟)
|
|
|
|
|
|
console.log('[录音] 等待文件完全保存...')
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
console.log('[录音] 文件已完全保存,准备返回')
|
|
|
|
|
|
resolve(this.tempFilePath)
|
|
|
|
|
|
}, 500)
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100)
|
|
|
|
|
|
}, 800)
|
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) => {
|
2025-12-07 08:40:26 +08:00
|
|
|
|
// 使用导入的服务器配置
|
2025-12-07 01:19:40 +08:00
|
|
|
|
// 开发环境可以用 localhost:5000 测试
|
|
|
|
|
|
// const serverUrl = 'http://localhost:5000' // Windows本地测试
|
2025-12-07 00:11:06 +08:00
|
|
|
|
const serverUrl = config.API_BASE_URL
|
|
|
|
|
|
|
2025-12-07 08:40:26 +08:00
|
|
|
|
console.log('[上传] 开始上传录音文件')
|
|
|
|
|
|
console.log('[上传] 服务器地址:', serverUrl)
|
|
|
|
|
|
console.log('[上传] 文件路径:', filePath)
|
|
|
|
|
|
console.log('[上传] 参数:', params)
|
|
|
|
|
|
|
2025-12-07 00:11:06 +08:00
|
|
|
|
uni.uploadFile({
|
|
|
|
|
|
url: `${serverUrl}/api/speech/recognize`,
|
|
|
|
|
|
filePath: filePath,
|
|
|
|
|
|
name: 'audio',
|
|
|
|
|
|
formData: {
|
|
|
|
|
|
...params,
|
2025-12-07 08:40:26 +08:00
|
|
|
|
format: 'wav', // 匹配录音格式
|
2025-12-07 00:11:06 +08:00
|
|
|
|
sampleRate: 16000
|
|
|
|
|
|
},
|
|
|
|
|
|
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()
|