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 } // 构建formData,courseId为空时不传 const formData = { content: content, language: language } if (courseId) { formData.courseId = courseId } uni.uploadFile({ url: request.baseURL + '/study/voiceEvaluation/uploadAndEvaluate', filePath: filePath, name: 'file', formData: formData, header: { 'Authorization': 'Bearer ' + token }, success: (res) => { 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) { uni.uploadFile({ url: request.baseURL + `/study/voiceEvaluation/submit/${id}`, 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 } uni.uploadFile({ url: request.baseURL + '/study/voiceEvaluation/uploadAndRecognize', filePath: audioFilePath, name: 'file', formData: { originalContent: originalContent || '' }, 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)) } }) }) }