guoyu/fronted_uniapp/api/study/voiceEvaluation.js

527 lines
19 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.

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)
// 构建formDatacourseId为空时不传
const formData = {
content: content,
language: language,
format: format // 添加format参数必须传给后端
}
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
})
uni.uploadFile({
url: baseURL + '/study/voiceEvaluation/uploadAndEvaluate',
filePath: filePath,
name: 'file',
formData: formData,
header: {
'Authorization': 'Bearer ' + token
},
success: (res) => {
uni.hideToast()
console.log('✅ 上传成功,服务器响应:', 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) {
const baseURL = request.getBaseURL()
uni.uploadFile({
url: 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
}
// 从文件路径提取格式
let format = 'pcm' // 默认pcm
if (audioFilePath) {
const ext = audioFilePath.substring(audioFilePath.lastIndexOf('.') + 1).toLowerCase()
if (ext) {
format = ext
}
}
console.log('上传音频文件:', audioFilePath, '格式:', format)
const baseURL = request.getBaseURL()
uni.uploadFile({
url: baseURL + '/study/voiceEvaluation/uploadAndRecognize',
filePath: audioFilePath,
name: 'file',
timeout: 60000, // 增加超时时间到60秒虚拟机网络可能较慢
formData: {
originalContent: originalContent || '',
format: format // 添加format参数
},
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))
}
})
})
}