527 lines
19 KiB
JavaScript
527 lines
19 KiB
JavaScript
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)
|
||
|
||
// 构建formData,courseId为空时不传
|
||
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))
|
||
}
|
||
})
|
||
})
|
||
}
|