guoyu/fronted_uniapp/api/study/voiceEvaluation.js

527 lines
19 KiB
JavaScript
Raw Normal View History

2025-12-03 18:58:36 +08:00
import request from '@/utils/request.js'
/**
* 语音评测API
*/
// 上传音频并进行评测
export function uploadAndEvaluate(filePath, content, courseId, language = 'zh-CN') {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token')
if (!token) {
reject(new Error('请先登录'))
return
}
// 从文件路径提取格式
let format = 'pcm' // 默认pcm
if (filePath) {
const ext = filePath.substring(filePath.lastIndexOf('.') + 1).toLowerCase()
if (ext) {
format = ext
}
}
console.log('📤 上传音频文件:', filePath, '格式:', format)
2025-12-03 18:58:36 +08:00
// 构建formDatacourseId为空时不传
const formData = {
content: content,
language: language,
format: format // 添加format参数必须传给后端
2025-12-03 18:58:36 +08:00
}
if (courseId) {
formData.courseId = courseId
}
const baseURL = request.getBaseURL()
console.log('========================================')
console.log('📤 准备上传音频文件')
console.log('文件路径:', filePath)
console.log('格式:', format)
console.log('上传地址:', baseURL + '/study/voiceEvaluation/uploadAndEvaluate')
console.log('FormData:', formData)
console.log('========================================')
// 显示上传提示
uni.showToast({
title: '正在上传录音...',
icon: 'loading',
duration: 10000
})
2025-12-03 18:58:36 +08:00
uni.uploadFile({
url: baseURL + '/study/voiceEvaluation/uploadAndEvaluate',
2025-12-03 18:58:36 +08:00
filePath: filePath,
name: 'file',
formData: formData,
header: {
'Authorization': 'Bearer ' + token
},
success: (res) => {
uni.hideToast()
console.log('✅ 上传成功,服务器响应:', res)
2025-12-03 18:58:36 +08:00
try {
const data = JSON.parse(res.data)
if (data.code === 200) {
resolve(data)
} else {
// 处理401未授权错误
if (data.code === 401) {
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
uni.reLaunch({
url: '/pages/login/login'
})
reject(new Error('登录已过期,请重新登录'))
} else {
reject(new Error(data.msg || '评测失败'))
}
}
} catch (e) {
console.error('解析响应数据失败', e, res.data)
reject(new Error('解析响应数据失败:' + e.message))
}
},
fail: (err) => {
console.error('上传失败', err)
let errorMsg = '上传失败'
if (err.errMsg) {
if (err.errMsg.includes('timeout')) {
errorMsg = '上传超时,请检查网络连接'
} else if (err.errMsg.includes('fail')) {
errorMsg = '网络请求失败,请检查网络连接'
}
}
reject(new Error(errorMsg))
}
})
})
}
// 获取我的语音评测记录
export function getMyVoiceRecords(courseId) {
const params = courseId ? { courseId } : {}
return request.get('/study/voiceEvaluation/my-records', params)
}
// 提交语音评测(支持可选的音频文件上传)
export function submitVoiceEvaluation(id, audioFilePath) {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token')
if (!token) {
reject(new Error('请先登录'))
return
}
// 如果提供了音频文件路径,使用文件上传方式
if (audioFilePath) {
const baseURL = request.getBaseURL()
2025-12-03 18:58:36 +08:00
uni.uploadFile({
url: baseURL + `/study/voiceEvaluation/submit/${id}`,
2025-12-03 18:58:36 +08:00
filePath: audioFilePath,
name: 'file',
formData: {},
header: {
'Authorization': 'Bearer ' + token
},
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.code === 200) {
resolve(data)
} else {
if (data.code === 401) {
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
uni.reLaunch({
url: '/pages/login/login'
})
reject(new Error('登录已过期,请重新登录'))
} else {
reject(new Error(data.msg || '提交失败'))
}
}
} catch (e) {
console.error('解析响应数据失败', e, res.data)
reject(new Error('解析响应数据失败:' + e.message))
}
},
fail: (err) => {
console.error('提交失败', err)
let errorMsg = '提交失败'
if (err.errMsg) {
if (err.errMsg.includes('timeout')) {
errorMsg = '提交超时,请检查网络连接'
} else if (err.errMsg.includes('fail')) {
errorMsg = '网络请求失败,请检查网络连接'
}
}
reject(new Error(errorMsg))
}
})
} else {
// 如果没有音频文件使用普通POST请求
return request.post(`/study/voiceEvaluation/submit/${id}`, {})
.then(resolve)
.catch(reject)
}
})
}
// 保存语音识别评分结果speech.vue页面使用
export function saveVoiceScore(content, recognizedText, totalScore, accuracy, completeness, fluency, pronunciation, courseId, resultDetail) {
const data = {
content: content,
recognizedText: recognizedText,
totalScore: totalScore,
accuracy: accuracy,
completeness: completeness,
fluency: fluency,
pronunciation: pronunciation,
resultDetail: resultDetail || ''
}
if (courseId) {
data.courseId = courseId
}
return request.post('/study/voiceEvaluation/saveScore', data)
}
// 获取语音评测详情
export function getVoiceEvaluation(id) {
return request.get(`/study/voiceEvaluation/${id}`, {})
}
// 获取后台发布的语音测评内容列表
export function getVoiceEvaluationContents(params = {}) {
return request.get('/study/voiceEvaluation/contents', params)
}
// 获取单个语音测评内容详情
export function getVoiceEvaluationContent(id) {
return request.get(`/study/voiceEvaluation/content/${id}`, {})
}
// 提交语音识别结果并获取评分(本地评分)
export function evaluateSpeechRecognition(originalText, recognizedText) {
// 这是一个本地评分函数,不需要调用后端
// 但为了保持API结构一致这里返回一个Promise
return new Promise((resolve) => {
const score = calculateScore(originalText, recognizedText)
resolve({
code: 200,
data: score
})
})
}
// 计算评分(本地算法)
function calculateScore(originalText, recognizedText) {
if (!originalText || !recognizedText) {
return {
totalScore: 0,
accuracy: 0,
completeness: 0,
fluency: 0,
pronunciation: 0,
details: {
correctWords: 0,
totalWords: 0,
missingWords: [],
extraWords: [],
similarity: 0
}
}
}
// 清理文本:去除标点符号和空格,转为小写
const cleanOriginal = originalText.replace(/[,。!?、;:""''()【】\s]/g, '').toLowerCase()
const cleanRecognized = recognizedText.replace(/[,。!?、;:""''()【】\s]/g, '').toLowerCase()
// 计算字符相似度(使用编辑距离算法)
const similarity = calculateSimilarity(cleanOriginal, cleanRecognized)
// 使用更宽松的字符匹配(不要求顺序一致)
const originalChars = cleanOriginal.split('')
const recognizedChars = cleanRecognized.split('')
// 统计字符出现次数
const originalCharMap = {}
const recognizedCharMap = {}
originalChars.forEach(char => {
originalCharMap[char] = (originalCharMap[char] || 0) + 1
})
recognizedChars.forEach(char => {
recognizedCharMap[char] = (recognizedCharMap[char] || 0) + 1
})
// 计算匹配的字符数(允许顺序不同)
let matchedChars = 0
Object.keys(originalCharMap).forEach(char => {
const originalCount = originalCharMap[char]
const recognizedCount = recognizedCharMap[char] || 0
matchedChars += Math.min(originalCount, recognizedCount)
})
const totalOriginalChars = originalChars.length
const totalRecognizedChars = recognizedChars.length
// 计算编辑距离(用于计算错误数)
const editDistance = calculateEditDistance(cleanOriginal, cleanRecognized)
const errorCount = editDistance
const errorRate = totalOriginalChars > 0 ? errorCount / totalOriginalChars : 1
// 计算准确度(基于匹配字符数,宽松)
const baseAccuracy = totalOriginalChars > 0
? (matchedChars / totalOriginalChars) * 100
: 0
// 准确度有错字时轻微扣分每个错字扣1分
const accuracyPenalty = Math.min(errorCount * 1, baseAccuracy * 0.1) // 最多扣10%
const accuracy = Math.max(0, baseAccuracy - accuracyPenalty)
// 计算完整度(识别文本长度与原文长度的比例,宽松)
const completeness = totalOriginalChars > 0
? Math.min((totalRecognizedChars / totalOriginalChars) * 100 * 1.1, 100) // 10%容错
: 0
// 流畅度(基于相似度,宽松)
const fluency = Math.min(similarity * 100 * 1.15, 100) // 15%容错
// 发音分数(基于准确度和相似度的综合,宽松)
const pronunciation = Math.min((accuracy * 0.7 + similarity * 100 * 0.3) * 1.1, 100) // 10%容错
// 基础总分(加权平均)
let totalScore = Math.round(
accuracy * 0.3 +
completeness * 0.25 +
fluency * 0.3 +
pronunciation * 0.15
)
// ========== 宽松评分标准 ==========
// 100分优秀匹配相似度>=0.98且错误数<=1
if (similarity >= 0.98 && errorCount <= 1) {
if (errorCount === 0 && similarity >= 0.999) {
totalScore = 100
} else {
// 1个错误但相似度很高给98-99分
totalScore = Math.max(98, Math.min(99, 98 + Math.round((similarity - 0.98) * 50)))
}
}
// 90-99分良好匹配允许2-4个错误相似度>=0.90
else if (errorCount <= 4 && similarity >= 0.90) {
if (errorCount <= 1) {
// 0-1个错误95-99分
totalScore = Math.max(95, Math.min(99, 95 + Math.round((similarity - 0.90) * 40)))
} else if (errorCount === 2) {
// 2个错误92-95分
totalScore = Math.max(92, Math.min(95, 92 + Math.round((similarity - 0.90) * 30)))
} else {
// 3-4个错误90-92分
totalScore = Math.max(90, Math.min(92, 90 + Math.round((similarity - 0.90) * 20)))
}
}
// 80-89分较好允许5-8个错误相似度>=0.80
else if (errorCount <= 8 && similarity >= 0.80) {
const baseScore = 80
const penalty = Math.max(0, (errorCount - 4) * 1.5) // 每个错误扣1.5分
totalScore = Math.max(80, Math.min(89, baseScore - penalty + Math.round((similarity - 0.80) * 30)))
}
// 70-79分一般允许9-15个错误相似度>=0.70
else if (errorCount <= 15 && similarity >= 0.70) {
const baseScore = 70
const penalty = Math.max(0, (errorCount - 8) * 1.2) // 每个错误扣1.2分
totalScore = Math.max(70, Math.min(79, baseScore - penalty + Math.round((similarity - 0.70) * 30)))
}
// 60-69分及格允许16-25个错误相似度>=0.60
else if (errorCount <= 25 && similarity >= 0.60) {
const baseScore = 60
const penalty = Math.max(0, (errorCount - 15) * 0.8) // 每个错误扣0.8分
totalScore = Math.max(60, Math.min(69, baseScore - penalty + Math.round((similarity - 0.60) * 30)))
}
// 60分以下需要改进但给予基础鼓励分
else {
// 根据相似度和错误数计算基础分,但给予最低保障
const baseScore = Math.max(0, Math.round(similarity * 100 - errorCount * 2)) // 降低扣分力度
// 如果相似度>=0.50至少给40分
if (similarity >= 0.50) {
totalScore = Math.max(40, Math.min(59, baseScore + 10))
} else {
totalScore = Math.max(0, Math.min(59, baseScore))
}
}
// 确保分数在0-100范围内
totalScore = Math.max(0, Math.min(100, totalScore))
// 找出缺失和多余的字符
const missingWords = []
const extraWords = []
Object.keys(originalCharMap).forEach(char => {
const originalCount = originalCharMap[char]
const recognizedCount = recognizedCharMap[char] || 0
if (recognizedCount < originalCount) {
for (let i = 0; i < originalCount - recognizedCount; i++) {
missingWords.push(char)
}
}
})
Object.keys(recognizedCharMap).forEach(char => {
const originalCount = originalCharMap[char] || 0
const recognizedCount = recognizedCharMap[char]
if (originalCount < recognizedCount) {
for (let i = 0; i < recognizedCount - originalCount; i++) {
extraWords.push(char)
}
}
})
return {
totalScore: Math.min(totalScore, 100),
accuracy: Math.round(Math.min(accuracy, 100)),
completeness: Math.round(Math.min(completeness, 100)),
fluency: Math.round(Math.min(fluency, 100)),
pronunciation: Math.round(Math.min(pronunciation, 100)),
details: {
correctChars: matchedChars,
totalChars: totalOriginalChars,
recognizedChars: totalRecognizedChars,
missingWords: [...new Set(missingWords)],
extraWords: [...new Set(extraWords)],
similarity: Math.round(similarity * 100)
}
}
}
// 计算编辑距离(用于计算错误数)
function calculateEditDistance(str1, str2) {
if (str1 === str2) return 0
if (str1.length === 0) return str2.length
if (str2.length === 0) return str1.length
const len1 = str1.length
const len2 = str2.length
const matrix = []
// 初始化矩阵
for (let i = 0; i <= len1; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= len2; j++) {
matrix[0][j] = j
}
// 填充矩阵
for (let i = 1; i <= len1; i++) {
for (let j = 1; j <= len2; j++) {
if (str1[i - 1] === str2[j - 1]) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j] + 1, // 删除
matrix[i][j - 1] + 1, // 插入
matrix[i - 1][j - 1] + 1 // 替换
)
}
}
}
return matrix[len1][len2]
}
// 计算两个字符串的相似度(使用编辑距离算法)
function calculateSimilarity(str1, str2) {
if (str1 === str2) return 1
if (str1.length === 0 || str2.length === 0) return 0
const distance = calculateEditDistance(str1, str2)
const maxLen = Math.max(str1.length, str2.length)
return 1 - (distance / maxLen)
}
// 使用AI优化语音识别结果
export function optimizeRecognizedText(recognizedText, originalContent) {
return request.post('/study/voiceEvaluation/optimizeText', {
recognizedText: recognizedText,
originalContent: originalContent
})
}
// 上传音频文件并进行语音识别使用后端服务识别比本地Vosk更准确
export function uploadAndRecognize(audioFilePath, originalContent) {
return new Promise((resolve, reject) => {
const token = uni.getStorageSync('token')
if (!token) {
reject(new Error('请先登录'))
return
}
// 从文件路径提取格式
let format = 'pcm' // 默认pcm
if (audioFilePath) {
const ext = audioFilePath.substring(audioFilePath.lastIndexOf('.') + 1).toLowerCase()
if (ext) {
format = ext
}
}
console.log('上传音频文件:', audioFilePath, '格式:', format)
2025-12-03 18:58:36 +08:00
const baseURL = request.getBaseURL()
2025-12-03 18:58:36 +08:00
uni.uploadFile({
url: baseURL + '/study/voiceEvaluation/uploadAndRecognize',
2025-12-03 18:58:36 +08:00
filePath: audioFilePath,
name: 'file',
timeout: 60000, // 增加超时时间到60秒虚拟机网络可能较慢
2025-12-03 18:58:36 +08:00
formData: {
originalContent: originalContent || '',
format: format // 添加format参数
2025-12-03 18:58:36 +08:00
},
header: {
'Authorization': 'Bearer ' + token
},
success: (res) => {
try {
const data = JSON.parse(res.data)
if (data.code === 200) {
resolve(data)
} else {
if (data.code === 401) {
uni.removeStorageSync('token')
uni.removeStorageSync('userInfo')
uni.reLaunch({
url: '/pages/login/login'
})
reject(new Error('登录已过期,请重新登录'))
} else {
reject(new Error(data.msg || '识别失败'))
}
}
} catch (e) {
console.error('解析响应数据失败', e, res.data)
reject(new Error('解析响应数据失败:' + e.message))
}
},
fail: (err) => {
console.error('上传失败', err)
let errorMsg = '上传失败'
if (err.errMsg) {
if (err.errMsg.includes('timeout')) {
errorMsg = '上传超时,请检查网络连接'
} else if (err.errMsg.includes('fail')) {
errorMsg = '网络请求失败,请检查网络连接'
}
}
reject(new Error(errorMsg))
}
})
})
}