修改所有bug
This commit is contained in:
parent
c01d45b200
commit
dfb1c4a7b7
|
|
@ -275,7 +275,6 @@ public class StudyClassUserController extends BaseController
|
|||
StudentImportData sample = new StudentImportData();
|
||||
sample.setUserId("001");
|
||||
sample.setUserName("张三");
|
||||
sample.setPrisonName("XX监狱");
|
||||
sample.setPrisonArea("一监区");
|
||||
sample.setClassName("一班");
|
||||
sample.setSex("男");
|
||||
|
|
|
|||
|
|
@ -515,6 +515,20 @@ public class StudyVoiceEvaluationController extends BaseController
|
|||
|
||||
Map<String, Object> evaluationResult = voiceService.evaluateVoice(fileName, content, language, format);
|
||||
|
||||
// ✅ 从resultDetail中提取recognizedText
|
||||
String recognizedText = "";
|
||||
String resultDetail = (String) evaluationResult.get("resultDetail");
|
||||
if (resultDetail != null && resultDetail.contains("recognizedText")) {
|
||||
try {
|
||||
com.fasterxml.jackson.databind.ObjectMapper mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
Map<String, Object> detailMap = mapper.readValue(resultDetail, Map.class);
|
||||
recognizedText = (String) detailMap.getOrDefault("recognizedText", "");
|
||||
logger.info("✅ 提取识别文本: {}", recognizedText);
|
||||
} catch (Exception e) {
|
||||
logger.warn("解析resultDetail失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 保存评测记录
|
||||
StudyVoiceEvaluation voiceEvaluation = new StudyVoiceEvaluation();
|
||||
voiceEvaluation.setStudentId(studentId);
|
||||
|
|
@ -537,6 +551,8 @@ public class StudyVoiceEvaluationController extends BaseController
|
|||
AjaxResult ajax = AjaxResult.success("评测完成");
|
||||
ajax.put("evaluation", voiceEvaluation);
|
||||
ajax.put("audioUrl", audioUrl);
|
||||
// ✅ 添加recognizedText到返回数据
|
||||
ajax.put("recognizedText", recognizedText);
|
||||
return ajax;
|
||||
}
|
||||
catch (Exception e)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ spring:
|
|||
master:
|
||||
url: jdbc:mysql://127.0.0.1:3306/study?useUnicode=true&characterEncoding=utf8&zeroDateTimeBehavior=convertToNull&useSSL=false&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true
|
||||
username: root
|
||||
password: root
|
||||
password: 123456
|
||||
# 从库数据源
|
||||
slave:
|
||||
# 从数据源开关/默认关闭
|
||||
|
|
|
|||
|
|
@ -20,12 +20,11 @@ public class StudentImportData
|
|||
@Excel(name = "罪犯姓名", sort = 2, prompt = "必填项,如:张三")
|
||||
private String userName;
|
||||
|
||||
/** 监狱(可选) */
|
||||
@Excel(name = "监狱", sort = 3, prompt = "可选,如:第一监狱")
|
||||
/** 监狱(不导出到模板,仅保留字段用于兼容旧数据导入) */
|
||||
private String prisonName;
|
||||
|
||||
/** 监区 - 支持多种列名匹配 */
|
||||
@Excel(name = "监区", sort = 4, prompt = "必填项,如:第三监区")
|
||||
@Excel(name = "监区", sort = 3, prompt = "必填项,如:第三监区")
|
||||
private String prisonArea;
|
||||
|
||||
/** 班级名称(用于按名称匹配班级,可选) */
|
||||
|
|
|
|||
|
|
@ -48,22 +48,27 @@ public class StudyVoiceEvaluation extends BaseEntity
|
|||
|
||||
/** 评分(总分,0-100) */
|
||||
@Excel(name = "总分", cellType = ColumnType.NUMERIC)
|
||||
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class)
|
||||
private BigDecimal score;
|
||||
|
||||
/** 准确度(0-100) */
|
||||
@Excel(name = "准确度", cellType = ColumnType.NUMERIC)
|
||||
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class)
|
||||
private BigDecimal accuracy;
|
||||
|
||||
/** 流畅度(0-100) */
|
||||
@Excel(name = "流畅度", cellType = ColumnType.NUMERIC)
|
||||
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class)
|
||||
private BigDecimal fluency;
|
||||
|
||||
/** 完整度(0-100) */
|
||||
@Excel(name = "完整度", cellType = ColumnType.NUMERIC)
|
||||
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class)
|
||||
private BigDecimal completeness;
|
||||
|
||||
/** 发音得分(0-100) */
|
||||
@Excel(name = "发音得分", cellType = ColumnType.NUMERIC)
|
||||
@com.fasterxml.jackson.databind.annotation.JsonSerialize(using = com.fasterxml.jackson.databind.ser.std.ToStringSerializer.class)
|
||||
private BigDecimal pronunciation;
|
||||
|
||||
/** 评测详情(JSON格式,包含详细评测结果) */
|
||||
|
|
|
|||
|
|
@ -123,13 +123,27 @@ public class DeepSeekService {
|
|||
return parseEvaluationResponse(response, recognizedText, standardText);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理文本:去除标点符号和空格,只保留文字
|
||||
*/
|
||||
private String cleanText(String text) {
|
||||
if (text == null) return "";
|
||||
// 去除所有标点符号(\p{P})、符号(\p{S})和空格(\s),只保留中文、英文、数字
|
||||
return text.replaceAll("[\\p{P}\\p{S}\\s]+", "").toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建评测提示词
|
||||
*/
|
||||
private String buildEvaluationPrompt(String recognizedText, String standardText) {
|
||||
// ✅ 清理标点符号,只评测文字内容
|
||||
String cleanRecognized = cleanText(recognizedText);
|
||||
String cleanStandard = cleanText(standardText);
|
||||
|
||||
return "你是一位专业的语音评测专家。请对以下语音识别结果进行评测:\n\n" +
|
||||
"[标准文本] " + standardText + "\n" +
|
||||
"[识别文本] " + recognizedText + "\n\n" +
|
||||
"[标准文本] " + cleanStandard + "\n" +
|
||||
"[识别文本] " + cleanRecognized + "\n\n" +
|
||||
"注意:已去除标点符号,只评测文字内容。\n\n" +
|
||||
"请从以下维度进行评分(每项0-100分):\n" +
|
||||
"1. 准确度(accuracy): 文本是否与标准一致\n" +
|
||||
"2. 完整度(completeness): 是否完整表达了标准内容\n" +
|
||||
|
|
@ -218,8 +232,9 @@ public class DeepSeekService {
|
|||
* 计算文本相似度
|
||||
*/
|
||||
private double calculateSimilarity(String text1, String text2) {
|
||||
String clean1 = text1.replaceAll("\\s+", "").toLowerCase();
|
||||
String clean2 = text2.replaceAll("\\s+", "").toLowerCase();
|
||||
// ✅ 使用 cleanText 方法,自动去除标点符号和空格
|
||||
String clean1 = cleanText(text1);
|
||||
String clean2 = cleanText(text2);
|
||||
|
||||
int maxLen = Math.max(clean1.length(), clean2.length());
|
||||
if (maxLen == 0) return 1.0;
|
||||
|
|
|
|||
|
|
@ -261,7 +261,7 @@
|
|||
</el-dialog>
|
||||
|
||||
<!-- 查看对话框 -->
|
||||
<el-dialog title="语音评测详情" :visible.sync="viewOpen" width="800px" append-to-body>
|
||||
<el-dialog title="语音评测详情" :visible.sync="viewOpen" width="800px" append-to-body :close-on-click-modal="false" class="view-dialog">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="评测ID">{{ form.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="学员姓名">{{ form.studentName }}</el-descriptions-item>
|
||||
|
|
@ -271,19 +271,20 @@
|
|||
<el-tag v-if="form.isSubmitted === 1" type="success" size="small">已提交</el-tag>
|
||||
<el-tag v-else type="info" size="small">未提交</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提交时间" v-if="form.submitTime">
|
||||
{{ parseTime(form.submitTime, '{y}-{m}-{d} {h}:{i}:{s}') }}
|
||||
<el-descriptions-item label="提交时间">
|
||||
<span v-if="form.submitTime">{{ parseTime(form.submitTime, '{y}-{m}-{d} {h}:{i}:{s}') }}</span>
|
||||
<span v-else style="color: #909399;">未提交</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="评测内容" :span="2">
|
||||
<div style="max-height: 100px; overflow-y: auto;">{{ form.content }}</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="总分">
|
||||
<span style="color: #409eff; font-weight: bold; font-size: 18px;">{{ form.score || 0 }}分</span>
|
||||
<el-descriptions-item label="总分" :span="2">
|
||||
<span style="color: #409eff; font-weight: bold; font-size: 18px;">{{ parseFloat(form.score || 0).toFixed(0) }}分</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="准确度">{{ form.accuracy || 0 }}分</el-descriptions-item>
|
||||
<el-descriptions-item label="流畅度">{{ form.fluency || 0 }}分</el-descriptions-item>
|
||||
<el-descriptions-item label="完整度">{{ form.completeness || 0 }}分</el-descriptions-item>
|
||||
<el-descriptions-item label="发音">{{ form.pronunciation || 0 }}分</el-descriptions-item>
|
||||
<el-descriptions-item label="准确度">{{ parseFloat(form.accuracy || 0).toFixed(0) }}分</el-descriptions-item>
|
||||
<el-descriptions-item label="完整度">{{ parseFloat(form.completeness || 0).toFixed(0) }}分</el-descriptions-item>
|
||||
<el-descriptions-item label="流畅度">{{ parseFloat(form.fluency || 0).toFixed(0) }}分</el-descriptions-item>
|
||||
<el-descriptions-item label="发音">{{ parseFloat(form.pronunciation || 0).toFixed(0) }}分</el-descriptions-item>
|
||||
<el-descriptions-item label="音频文件" :span="2">
|
||||
<div style="margin-top: 10px;">
|
||||
<div v-if="form.audioPath">
|
||||
|
|
@ -317,7 +318,7 @@
|
|||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="详细结果" :span="2" v-if="form.resultDetail">
|
||||
<pre style="max-height: 200px; overflow-y: auto; background-color: #f5f5f5; padding: 10px; border-radius: 4px;">{{ formatJson(form.resultDetail) }}</pre>
|
||||
<pre class="result-detail-pre">{{ formatJson(form.resultDetail) }}</pre>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div slot="footer" class="dialog-footer">
|
||||
|
|
@ -632,5 +633,38 @@ export default {
|
|||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 详细结果区域样式 */
|
||||
.result-detail-pre {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: #f5f7fa;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* 详情弹窗样式(不使用scoped,确保覆盖element-ui默认样式) */
|
||||
.view-dialog.el-dialog__wrapper {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding-top: 10vh;
|
||||
}
|
||||
.view-dialog .el-dialog {
|
||||
margin: 0 !important;
|
||||
}
|
||||
.view-dialog .el-dialog__body {
|
||||
padding: 20px;
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,292 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
本地Whisper语音识别服务
|
||||
替代百度API,完全离线运行
|
||||
|
||||
安装:
|
||||
pip install openai-whisper flask flask-cors
|
||||
|
||||
运行:
|
||||
python whisper_server.py
|
||||
|
||||
优点:
|
||||
1. 完全免费,无限次调用
|
||||
2. 离线运行,不需要网络
|
||||
3. 识别准确率高(接近百度API)
|
||||
4. 支持中文、英文等多语言
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
import whisper
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
|
||||
# 繁简体转换
|
||||
try:
|
||||
import zhconv
|
||||
HAS_ZHCONV = True
|
||||
except ImportError:
|
||||
HAS_ZHCONV = False
|
||||
print("⚠️ 未安装zhconv库,无法进行繁简体转换")
|
||||
print(" 安装命令: pip install zhconv")
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局变量:Whisper模型
|
||||
whisper_model = None
|
||||
|
||||
def load_whisper_model():
|
||||
"""加载Whisper模型(懒加载)"""
|
||||
global whisper_model
|
||||
if whisper_model is None:
|
||||
logger.info("正在加载Whisper模型...")
|
||||
# 使用base模型(平衡速度和准确度)
|
||||
# 可选: tiny, base, small, medium, large
|
||||
# tiny: 最快,准确度一般
|
||||
# base: 快速,准确度好 ✅ 推荐
|
||||
# small: 较慢,准确度高
|
||||
# medium/large: 很慢,准确度最高
|
||||
whisper_model = whisper.load_model("base")
|
||||
logger.info("✅ Whisper模型加载成功")
|
||||
return whisper_model
|
||||
|
||||
def convert_to_simplified(text):
|
||||
"""
|
||||
将繁体中文转换为简体中文
|
||||
|
||||
参数:
|
||||
text: 待转换的文本
|
||||
|
||||
返回:
|
||||
简体中文文本
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
if HAS_ZHCONV:
|
||||
try:
|
||||
# 使用zhconv进行繁简转换
|
||||
simplified = zhconv.convert(text, 'zh-cn')
|
||||
logger.info(f"繁简转换: {text} -> {simplified}")
|
||||
return simplified
|
||||
except Exception as e:
|
||||
logger.warning(f"繁简转换失败: {e}")
|
||||
return text
|
||||
else:
|
||||
# 如果没有安装zhconv,返回原文
|
||||
return text
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""健康检查"""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "Whisper语音识别服务",
|
||||
"model": "base"
|
||||
})
|
||||
|
||||
@app.route('/recognize', methods=['POST'])
|
||||
def recognize():
|
||||
"""
|
||||
语音识别接口
|
||||
|
||||
参数:
|
||||
- file: 音频文件(支持MP3, WAV, M4A等)
|
||||
- language: 语言(可选,默认自动检测)
|
||||
|
||||
返回:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "识别成功",
|
||||
"data": {
|
||||
"text": "识别的文本",
|
||||
"language": "zh",
|
||||
"confidence": 0.95
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 检查是否有文件
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
"code": 400,
|
||||
"msg": "未找到音频文件",
|
||||
"data": None
|
||||
}), 400
|
||||
|
||||
audio_file = request.files['file']
|
||||
language = request.form.get('language', 'zh') # 默认中文
|
||||
|
||||
# 保存临时文件
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as temp_file:
|
||||
audio_file.save(temp_file.name)
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# 加载模型
|
||||
model = load_whisper_model()
|
||||
|
||||
# 识别音频
|
||||
logger.info(f"开始识别音频: {audio_file.filename}")
|
||||
result = model.transcribe(
|
||||
temp_path,
|
||||
language=language,
|
||||
task='transcribe', # transcribe=识别,translate=翻译成英文
|
||||
fp16=False # CPU模式
|
||||
)
|
||||
|
||||
recognized_text = result['text'].strip()
|
||||
detected_language = result.get('language', language)
|
||||
|
||||
# ✅ 繁体转简体
|
||||
if detected_language == 'zh':
|
||||
recognized_text = convert_to_simplified(recognized_text)
|
||||
|
||||
logger.info(f"✅ 识别成功: {recognized_text}")
|
||||
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "识别成功",
|
||||
"data": {
|
||||
"text": recognized_text,
|
||||
"language": detected_language,
|
||||
"segments": len(result.get('segments', [])),
|
||||
"duration": result.get('duration', 0)
|
||||
}
|
||||
})
|
||||
|
||||
finally:
|
||||
# 删除临时文件
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"识别失败: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"msg": f"识别失败: {str(e)}",
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
@app.route('/evaluate', methods=['POST'])
|
||||
def evaluate():
|
||||
"""
|
||||
语音评测接口(完整功能)
|
||||
|
||||
参数:
|
||||
- file: 音频文件
|
||||
- text: 标准文本(用于对比)
|
||||
|
||||
返回:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "评测成功",
|
||||
"data": {
|
||||
"text": "识别的文本",
|
||||
"score": 95,
|
||||
"accuracy": 98,
|
||||
"fluency": 92,
|
||||
"completeness": 95,
|
||||
"pronunciation": 94
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"code": 400, "msg": "未找到音频文件", "data": None}), 400
|
||||
|
||||
audio_file = request.files['file']
|
||||
standard_text = request.form.get('text', '')
|
||||
|
||||
# 保存临时文件
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as temp_file:
|
||||
audio_file.save(temp_file.name)
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# 1. 识别音频
|
||||
model = load_whisper_model()
|
||||
result = model.transcribe(temp_path, language='zh', fp16=False)
|
||||
recognized_text = result['text'].strip()
|
||||
|
||||
# ✅ 繁体转简体
|
||||
recognized_text = convert_to_simplified(recognized_text)
|
||||
|
||||
# 2. 计算评分
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
# 清理文本
|
||||
clean_recognized = ''.join(recognized_text.split()).lower()
|
||||
clean_standard = ''.join(standard_text.split()).lower()
|
||||
|
||||
# 相似度
|
||||
similarity = SequenceMatcher(None, clean_recognized, clean_standard).ratio()
|
||||
|
||||
# 计算各项评分
|
||||
accuracy = similarity * 100 # 准确度
|
||||
completeness = min(len(clean_recognized) / max(len(clean_standard), 1) * 100, 100) # 完整度
|
||||
fluency = accuracy * 0.95 # 流利度(基于准确度)
|
||||
pronunciation = accuracy * 0.98 # 发音(基于准确度)
|
||||
|
||||
# 总分
|
||||
total_score = (accuracy * 0.3 + completeness * 0.25 + fluency * 0.3 + pronunciation * 0.15)
|
||||
|
||||
logger.info(f"✅ 评测完成: {recognized_text} | 得分: {total_score:.0f}")
|
||||
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "评测成功",
|
||||
"data": {
|
||||
"text": recognized_text,
|
||||
"score": round(total_score),
|
||||
"accuracy": round(accuracy),
|
||||
"fluency": round(fluency),
|
||||
"completeness": round(completeness),
|
||||
"pronunciation": round(pronunciation),
|
||||
"similarity": round(similarity * 100, 2)
|
||||
}
|
||||
})
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"评测失败: {str(e)}", exc_info=True)
|
||||
return jsonify({"code": 500, "msg": f"评测失败: {str(e)}", "data": None}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("🎤 本地Whisper语音识别服务")
|
||||
print("=" * 60)
|
||||
print("")
|
||||
print("✅ 优势:")
|
||||
print(" 1. 完全免费,无限次调用")
|
||||
print(" 2. 离线运行,不需要网络")
|
||||
print(" 3. 识别准确率高")
|
||||
print(" 4. 数据完全私有")
|
||||
print("")
|
||||
print("📦 安装依赖:")
|
||||
print(" pip install openai-whisper flask flask-cors")
|
||||
print("")
|
||||
print("🚀 启动服务:")
|
||||
print(" python whisper_server.py")
|
||||
print("")
|
||||
print("📌 API接口:")
|
||||
print(" 健康检查: GET http://localhost:5001/health")
|
||||
print(" 语音识别: POST http://localhost:5001/recognize")
|
||||
print(" 语音评测: POST http://localhost:5001/evaluate")
|
||||
print("")
|
||||
print("=" * 60)
|
||||
print("")
|
||||
|
||||
# 启动服务
|
||||
app.run(host='0.0.0.0', port=5001, debug=True)
|
||||
|
|
@ -246,7 +246,7 @@ export default {
|
|||
pageUnloaded: false, // 页面卸载标记
|
||||
recordStartTime: 0, // 录音开始时间
|
||||
recordingFailCount: 0, // 录音失败次数
|
||||
lastRecordedFilePath: null // ✅ 保存最后一次录音的文件路径
|
||||
lastRecordedFilePath: null // ✅ 保存服务器返回的音频路径
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
|
|
@ -506,37 +506,77 @@ export default {
|
|||
throw new Error('录音文件获取失败')
|
||||
}
|
||||
|
||||
// 2. 上传并评测(上传后会从服务器返回保存到数据库的路径)
|
||||
console.log('[Speech] 开始上传评测...')
|
||||
const { uploadAndRecognize } = await import('@/api/study/voiceEvaluation.js')
|
||||
const evalResult = await uploadAndRecognize(
|
||||
// 2. ✅ 上传并评测(使用 uploadAndEvaluate 一次性完成识别+评测)
|
||||
console.log('[Speech] ========================================')
|
||||
console.log('[Speech] 🎯 新版本代码 v2.0 - 统一评测模式')
|
||||
console.log('[Speech] 准备调用 uploadAndEvaluate')
|
||||
console.log('[Speech] 文件路径:', filePath)
|
||||
console.log('[Speech] 文件类型:', typeof filePath)
|
||||
console.log('[Speech] 评测内容:', this.selectedContent?.content)
|
||||
console.log('[Speech] ========================================')
|
||||
|
||||
// 显示版本提示
|
||||
uni.showToast({
|
||||
title: '新版本v2.0-统一评测',
|
||||
icon: 'none',
|
||||
duration: 1500
|
||||
})
|
||||
|
||||
const { uploadAndEvaluate } = await import('@/api/study/voiceEvaluation.js')
|
||||
const evalResult = await uploadAndEvaluate(
|
||||
filePath,
|
||||
this.selectedContent?.content || '测试文本'
|
||||
this.selectedContent?.content || '测试文本',
|
||||
null,
|
||||
'zh-CN'
|
||||
)
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
if (evalResult.code === 200 && evalResult.data) {
|
||||
const data = evalResult.data
|
||||
this.recognizedText = data.recognizedText || ''
|
||||
this.scoreResult = {
|
||||
score: data.totalScore || 0,
|
||||
pronunciationScore: data.pronunciation || 0,
|
||||
fluencyScore: data.fluency || 0
|
||||
}
|
||||
// 🔍 调试:输出完整返回数据
|
||||
console.log('[Speech] 返回结果 code:', evalResult.code)
|
||||
console.log('[Speech] 返回结果:', JSON.stringify(evalResult))
|
||||
console.log('[Speech] 是否有evaluation:', !!evalResult.evaluation)
|
||||
|
||||
// ✅ 使用服务器返回的相对路径(如 /profile/upload/voice/2025/12/11/xxx.mp3)
|
||||
if (data.audioPath) {
|
||||
this.lastRecordedFilePath = data.audioPath
|
||||
console.log('[Speech] 保存服务器音频路径:', data.audioPath)
|
||||
// ✅ 只检查 code === 200,不强制要求 evaluation
|
||||
if (evalResult.code === 200) {
|
||||
// 获取评测数据(可能在 evaluation 或 data 中)
|
||||
const evaluation = evalResult.evaluation || evalResult.data || {}
|
||||
const recognizedText = evalResult.recognizedText || evaluation.recognizedText || ''
|
||||
|
||||
// ✅ 设置识别文本
|
||||
this.recognizedText = recognizedText
|
||||
console.log('[Speech] 识别文本:', this.recognizedText)
|
||||
|
||||
// ✅ 设置评分结果(兼容多种数据格式)
|
||||
this.scoreResult = {
|
||||
totalScore: Number(evaluation.score) || Number(evaluation.totalScore) || 0,
|
||||
accuracy: Number(evaluation.accuracy) || 0,
|
||||
completeness: Number(evaluation.completeness) || 0,
|
||||
fluency: Number(evaluation.fluency) || 0,
|
||||
pronunciation: Number(evaluation.pronunciation) || 0
|
||||
}
|
||||
console.log('[Speech] 评分结果:', this.scoreResult)
|
||||
|
||||
// ✅ 保存评测ID和提交状态
|
||||
this.currentEvaluationId = evaluation.id
|
||||
const submitted = evaluation.isSubmitted
|
||||
this.isSubmitted = submitted === 1 || submitted === true || submitted === '1'
|
||||
|
||||
// ✅ 保存服务器音频路径
|
||||
if (evaluation.audioPath) {
|
||||
this.lastRecordedFilePath = evaluation.audioPath
|
||||
console.log('[Speech] 服务器音频路径:', evaluation.audioPath)
|
||||
}
|
||||
|
||||
this.hasFirstResult = true
|
||||
this.statusText = '评测完成'
|
||||
this.debugInfo = `得分:${data.totalScore}分`
|
||||
const finalScore = this.scoreResult.totalScore
|
||||
this.debugInfo = `得分:${finalScore}分`
|
||||
this.recordingFailCount = 0
|
||||
uni.showToast({ title: `得分:${data.totalScore}分`, icon: 'success' })
|
||||
uni.showToast({ title: `得分:${finalScore}分`, icon: 'success' })
|
||||
} else {
|
||||
console.error('[Speech] ❌ 评测失败,返回码:', evalResult.code)
|
||||
console.error('[Speech] evalResult:', evalResult)
|
||||
throw new Error(evalResult.msg || '评测失败')
|
||||
}
|
||||
} catch (error) {
|
||||
|
|
@ -677,27 +717,14 @@ export default {
|
|||
if (this.scrollTimer) { clearInterval(this.scrollTimer); this.scrollTimer = null }
|
||||
},
|
||||
async evaluateScore() {
|
||||
if (!this.recognizedText || !this.selectedContent) {
|
||||
uni.showToast({ title: '请先完成语音识别', icon: 'none' })
|
||||
// ✅ 已经在停止录音时完成评分,这里不需要再次评分
|
||||
if (this.scoreResult && this.scoreResult.totalScore !== undefined) {
|
||||
uni.showToast({ title: '已经评分完成', icon: 'none' })
|
||||
this.$nextTick(() => { uni.pageScrollTo({ selector: '.score-section', duration: 300 }) })
|
||||
return
|
||||
}
|
||||
this.isEvaluating = true
|
||||
try {
|
||||
const result = await evaluateSpeechRecognition(this.selectedContent.content, this.recognizedText)
|
||||
if (result.code === 200 && result.data) {
|
||||
this.scoreResult = result.data
|
||||
await this.saveScoreToBackend(result.data)
|
||||
uni.showToast({ title: '评分完成', icon: 'success', duration: 2000 })
|
||||
this.$nextTick(() => { uni.pageScrollTo({ selector: '.score-section', duration: 300 }) })
|
||||
} else {
|
||||
throw new Error(result.msg || '评分失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('评分失败', error)
|
||||
uni.showToast({ title: error.message || '评分失败', icon: 'none', duration: 3000 })
|
||||
} finally {
|
||||
this.isEvaluating = false
|
||||
}
|
||||
|
||||
uni.showToast({ title: '请先录音', icon: 'none' })
|
||||
},
|
||||
async saveScoreToBackend(scoreData) {
|
||||
if (!this.selectedContent || !this.recognizedText) return
|
||||
|
|
@ -769,6 +796,7 @@ export default {
|
|||
this.scoreResult = null
|
||||
this.currentEvaluationId = null
|
||||
this.isSubmitted = false
|
||||
this.lastRecordedFilePath = null // ✅ 清空服务器路径
|
||||
},
|
||||
getDifficultyText(difficulty) {
|
||||
const map = { 'easy': '简单', 'medium': '中等', 'hard': '困难' }
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
# 语音评测新方案 - 快速开始
|
||||
|
||||
## ✅ 已完成
|
||||
|
||||
1. ✅ **禁用 UTS 插件** - 不再依赖本地编译
|
||||
2. ✅ **创建录音工具** - `utils/speech-recorder.js`
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
- **路径**: `Study-Vue-redis/`
|
||||
- **技术栈**: Spring Boot + MyBatis + Vue.js
|
||||
- **说明**: 包含后端API服务和管理后台前端
|
||||
|
||||
4-
|
||||
### 2. APP项目
|
||||
- **路径**: `fronted_uniapp/`
|
||||
- **技术栈**: uni-app (Vue.js)
|
||||
|
|
|
|||
555
log/whisper_server (1).py
Normal file
555
log/whisper_server (1).py
Normal file
|
|
@ -0,0 +1,555 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Whisper语音识别服务
|
||||
支持语音识别和评测功能
|
||||
多线程并发处理
|
||||
作者: AI Assistant
|
||||
版本: 2.0
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
import whisper
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
import sys
|
||||
|
||||
# ============================================
|
||||
# 日志配置
|
||||
# ============================================
|
||||
def setup_logging():
|
||||
"""配置日志系统"""
|
||||
# 创建logger
|
||||
logger = logging.getLogger('whisper_server')
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# 控制台输出
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setLevel(logging.INFO)
|
||||
console_formatter = logging.Formatter(
|
||||
'%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%Y-%m-%d %H:%M:%S'
|
||||
)
|
||||
console_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# 文件输出(可选)
|
||||
try:
|
||||
log_dir = 'logs'
|
||||
if not os.path.exists(log_dir):
|
||||
os.makedirs(log_dir)
|
||||
|
||||
file_handler = RotatingFileHandler(
|
||||
os.path.join(log_dir, 'whisper_server.log'),
|
||||
maxBytes=10*1024*1024, # 10MB
|
||||
backupCount=5,
|
||||
encoding='utf-8'
|
||||
)
|
||||
file_handler.setLevel(logging.INFO)
|
||||
file_handler.setFormatter(console_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
except Exception as e:
|
||||
logger.warning(f"无法创建日志文件: {e}")
|
||||
|
||||
return logger
|
||||
|
||||
# 初始化日志
|
||||
logger = setup_logging()
|
||||
|
||||
# ============================================
|
||||
# Flask应用配置
|
||||
# ============================================
|
||||
app = Flask(__name__)
|
||||
CORS(app) # 允许跨域请求
|
||||
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 最大16MB
|
||||
|
||||
# ============================================
|
||||
# 全局变量
|
||||
# ============================================
|
||||
whisper_model = None
|
||||
MODEL_NAME = "tiny" # 可选: tiny, base, small, medium, large
|
||||
|
||||
# ============================================
|
||||
# 模型加载
|
||||
# ============================================
|
||||
def load_whisper_model():
|
||||
"""加载Whisper模型(全局单例)"""
|
||||
global whisper_model
|
||||
if whisper_model is None:
|
||||
logger.info(f"正在加载Whisper模型 ({MODEL_NAME})...")
|
||||
try:
|
||||
whisper_model = whisper.load_model(MODEL_NAME)
|
||||
logger.info(f"✅ Whisper模型加载成功 ({MODEL_NAME})")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 模型加载失败: {e}")
|
||||
raise
|
||||
return whisper_model
|
||||
|
||||
# ============================================
|
||||
# 工具函数
|
||||
# ============================================
|
||||
def convert_to_simplified(text):
|
||||
"""将繁体中文转换为简体中文"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
# 方案1:使用zhconv(推荐,纯Python)
|
||||
try:
|
||||
import zhconv
|
||||
result = zhconv.convert(text, 'zh-cn')
|
||||
if result != text:
|
||||
logger.info(f"繁简转换: {text} -> {result}")
|
||||
return result
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 方案2:使用opencc
|
||||
try:
|
||||
from opencc import OpenCC
|
||||
cc = OpenCC('t2s')
|
||||
result = cc.convert(text)
|
||||
if result != text:
|
||||
logger.info(f"繁简转换(OpenCC): {text} -> {result}")
|
||||
return result
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# 都没安装,返回原文
|
||||
logger.warning("繁简转换库未安装,请运行: pip install zhconv")
|
||||
return text
|
||||
|
||||
def clean_text_strict(text):
|
||||
"""
|
||||
严格清理文本,只保留汉字、字母、数字
|
||||
用于准确度计算
|
||||
"""
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Unicode标准化
|
||||
text = unicodedata.normalize('NFKC', text)
|
||||
|
||||
# 只保留汉字、字母、数字
|
||||
text = re.sub(r'[^\u4e00-\u9fffa-zA-Z0-9]', '', text)
|
||||
|
||||
return text.lower()
|
||||
|
||||
# ============================================
|
||||
# API路由
|
||||
# ============================================
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""健康检查接口"""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "Whisper Speech Recognition",
|
||||
"model": MODEL_NAME,
|
||||
"version": "2.0",
|
||||
"timestamp": datetime.now().isoformat()
|
||||
})
|
||||
|
||||
@app.route('/recognize', methods=['POST'])
|
||||
def recognize():
|
||||
"""
|
||||
语音识别接口
|
||||
只识别,不评测
|
||||
|
||||
参数:
|
||||
file: 音频文件 (multipart/form-data)
|
||||
|
||||
返回:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "识别成功",
|
||||
"data": {
|
||||
"text": "识别的文本"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 检查文件
|
||||
if 'file' not in request.files:
|
||||
logger.warning("请求缺少音频文件")
|
||||
return jsonify({"code": 400, "msg": "缺少音频文件", "data": None}), 400
|
||||
|
||||
audio_file = request.files['file']
|
||||
if audio_file.filename == '':
|
||||
logger.warning("文件名为空")
|
||||
return jsonify({"code": 400, "msg": "文件名为空", "data": None}), 400
|
||||
|
||||
logger.info(f"收到识别请求: {audio_file.filename} ({len(audio_file.read())} bytes)")
|
||||
audio_file.seek(0) # 重置文件指针
|
||||
|
||||
# 保存临时文件
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as temp_file:
|
||||
temp_path = temp_file.name
|
||||
audio_file.save(temp_path)
|
||||
|
||||
try:
|
||||
# 加载模型
|
||||
model = load_whisper_model()
|
||||
|
||||
# 识别音频
|
||||
logger.info(f"开始识别: {audio_file.filename}")
|
||||
result = model.transcribe(temp_path, language='zh', fp16=False)
|
||||
recognized_text = result['text'].strip()
|
||||
|
||||
# 繁体转简体
|
||||
recognized_text = convert_to_simplified(recognized_text)
|
||||
|
||||
logger.info(f"✅ 识别成功: {recognized_text}")
|
||||
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "识别成功",
|
||||
"data": {
|
||||
"text": recognized_text
|
||||
}
|
||||
})
|
||||
|
||||
finally:
|
||||
# 删除临时文件
|
||||
try:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"删除临时文件失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"识别失败: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"msg": f"识别失败: {str(e)}",
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
@app.route('/evaluate', methods=['POST'])
|
||||
def evaluate():
|
||||
"""
|
||||
语音评测接口
|
||||
识别 + 评分
|
||||
|
||||
参数:
|
||||
file: 音频文件 (multipart/form-data)
|
||||
text: 标准文本 (form-data)
|
||||
|
||||
返回:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "评测成功",
|
||||
"data": {
|
||||
"text": "识别的文本",
|
||||
"score": 95,
|
||||
"accuracy": 98,
|
||||
"fluency": 95,
|
||||
"completeness": 100,
|
||||
"pronunciation": 96,
|
||||
"similarity": 98.5
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 检查文件
|
||||
if 'file' not in request.files:
|
||||
logger.warning("请求缺少音频文件")
|
||||
return jsonify({"code": 400, "msg": "缺少音频文件", "data": None}), 400
|
||||
|
||||
audio_file = request.files['file']
|
||||
standard_text = request.form.get('text', '')
|
||||
|
||||
if not standard_text:
|
||||
logger.warning("请求缺少标准文本")
|
||||
return jsonify({"code": 400, "msg": "缺少标准文本", "data": None}), 400
|
||||
|
||||
logger.info(f"收到评测请求: {audio_file.filename}, 标准文本: {standard_text}")
|
||||
|
||||
# 保存临时文件
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as temp_file:
|
||||
temp_path = temp_file.name
|
||||
audio_file.save(temp_path)
|
||||
|
||||
try:
|
||||
# 1. 识别音频
|
||||
model = load_whisper_model()
|
||||
logger.info("开始识别音频...")
|
||||
result = model.transcribe(temp_path, language='zh', fp16=False)
|
||||
recognized_text = result['text'].strip()
|
||||
recognized_text = convert_to_simplified(recognized_text)
|
||||
|
||||
# 2. 计算评分
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
# 清理文本
|
||||
clean_recognized = clean_text_strict(recognized_text)
|
||||
clean_standard = clean_text_strict(standard_text)
|
||||
|
||||
# 相似度
|
||||
similarity = SequenceMatcher(None, clean_recognized, clean_standard).ratio()
|
||||
|
||||
# 调试日志
|
||||
logger.info(f"📝 标准文本: {standard_text}")
|
||||
logger.info(f"🎤 识别文本: {recognized_text}")
|
||||
logger.info(f"🧹 清理后标准: {clean_standard}")
|
||||
logger.info(f"🧹 清理后识别: {clean_recognized}")
|
||||
logger.info(f"📊 相似度: {similarity:.4f} ({similarity*100:.2f}%)")
|
||||
|
||||
# 准确度计算(带阈值优化)
|
||||
if similarity >= 0.98:
|
||||
accuracy = 100
|
||||
logger.info(f"✅ 相似度>=98%,准确度给满分: {accuracy}")
|
||||
elif similarity >= 0.95:
|
||||
accuracy = 99
|
||||
logger.info(f"✅ 相似度>=95%,准确度给99分: {accuracy}")
|
||||
else:
|
||||
accuracy = similarity * 100
|
||||
logger.info(f"📊 准确度: {accuracy:.2f}")
|
||||
|
||||
# 完整度
|
||||
completeness = min(len(clean_recognized) / max(len(clean_standard), 1) * 100, 100)
|
||||
|
||||
# 流畅度和发音(如果准确度满分,其他也满分)
|
||||
fluency = 100 if accuracy == 100 else accuracy * 0.95
|
||||
pronunciation = 100 if accuracy == 100 else accuracy * 0.98
|
||||
|
||||
# 总分(加权)
|
||||
total_score = (accuracy * 0.3 + completeness * 0.25 + fluency * 0.3 + pronunciation * 0.15)
|
||||
|
||||
logger.info(f"✅ 评测完成: 总分={total_score:.0f}, 准确度={accuracy:.0f}, 流畅度={fluency:.0f}, 完整度={completeness:.0f}, 发音={pronunciation:.0f}")
|
||||
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "评测成功",
|
||||
"data": {
|
||||
"text": recognized_text,
|
||||
"score": round(total_score),
|
||||
"accuracy": round(accuracy),
|
||||
"fluency": round(fluency),
|
||||
"completeness": round(completeness),
|
||||
"pronunciation": round(pronunciation),
|
||||
"similarity": round(similarity * 100, 2)
|
||||
}
|
||||
})
|
||||
|
||||
finally:
|
||||
# 删除临时文件
|
||||
try:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
except Exception as e:
|
||||
logger.warning(f"删除临时文件失败: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"评测失败: {str(e)}")
|
||||
logger.error(traceback.format_exc())
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"msg": f"评测失败: {str(e)}",
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
# ============================================
|
||||
# 错误处理
|
||||
# ============================================
|
||||
@app.errorhandler(413)
|
||||
def request_entity_too_large(error):
|
||||
logger.warning("请求文件过大")
|
||||
return jsonify({
|
||||
"code": 413,
|
||||
"msg": "文件太大,最大支持16MB",
|
||||
"data": None
|
||||
}), 413
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error(error):
|
||||
logger.error(f"服务器内部错误: {error}")
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"msg": "服务器内部错误",
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return jsonify({
|
||||
"code": 404,
|
||||
"msg": "接口不存在",
|
||||
"data": None
|
||||
}), 404
|
||||
|
||||
# ============================================
|
||||
# 环境检查
|
||||
# ============================================
|
||||
def check_environment():
|
||||
"""检查运行环境和依赖"""
|
||||
logger.info("")
|
||||
logger.info("=" * 70)
|
||||
logger.info("🔍 环境检查")
|
||||
logger.info("=" * 70)
|
||||
|
||||
all_ok = True
|
||||
|
||||
# 1. 检查 Python 版本
|
||||
py_version = sys.version.split()[0]
|
||||
logger.info(f" Python版本: {py_version} ✅")
|
||||
|
||||
# 2. 检查 whisper
|
||||
try:
|
||||
import whisper
|
||||
logger.info(f" Whisper: 已安装 ✅")
|
||||
except ImportError:
|
||||
logger.error(f" Whisper: 未安装 ❌ (pip install openai-whisper)")
|
||||
all_ok = False
|
||||
|
||||
# 3. 检查 Flask
|
||||
try:
|
||||
import flask
|
||||
logger.info(f" Flask: {flask.__version__} ✅")
|
||||
except ImportError:
|
||||
logger.error(f" Flask: 未安装 ❌ (pip install flask)")
|
||||
all_ok = False
|
||||
|
||||
# 4. 检查 flask-cors
|
||||
try:
|
||||
import flask_cors
|
||||
logger.info(f" Flask-CORS: 已安装 ✅")
|
||||
except ImportError:
|
||||
logger.error(f" Flask-CORS: 未安装 ❌ (pip install flask-cors)")
|
||||
all_ok = False
|
||||
|
||||
# 5. 检查 waitress
|
||||
try:
|
||||
import waitress
|
||||
logger.info(f" Waitress: 已安装 ✅")
|
||||
except ImportError:
|
||||
logger.error(f" Waitress: 未安装 ❌ (pip install waitress)")
|
||||
all_ok = False
|
||||
|
||||
# 6. 检查繁简转换库
|
||||
zhconv_ok = False
|
||||
opencc_ok = False
|
||||
|
||||
try:
|
||||
import zhconv
|
||||
zhconv_ok = True
|
||||
logger.info(f" zhconv: 已安装 ✅ (繁简转换)")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from opencc import OpenCC
|
||||
opencc_ok = True
|
||||
logger.info(f" OpenCC: 已安装 ✅ (繁简转换备用)")
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
if not zhconv_ok and not opencc_ok:
|
||||
logger.warning(f" 繁简转换: 未安装 ⚠️ (pip install zhconv)")
|
||||
logger.warning(f" 识别结果可能包含繁体字!")
|
||||
|
||||
# 7. 检查 ffmpeg(Whisper需要)
|
||||
import shutil
|
||||
if shutil.which('ffmpeg'):
|
||||
logger.info(f" FFmpeg: 已安装 ✅")
|
||||
else:
|
||||
logger.warning(f" FFmpeg: 未找到 ⚠️ (某些音频格式可能无法处理)")
|
||||
|
||||
logger.info("=" * 70)
|
||||
|
||||
if all_ok:
|
||||
logger.info("✅ 环境检查通过!")
|
||||
else:
|
||||
logger.error("❌ 缺少必要依赖,请先安装后再启动!")
|
||||
sys.exit(1)
|
||||
|
||||
logger.info("")
|
||||
return all_ok
|
||||
|
||||
# ============================================
|
||||
# 启动服务(多线程模式)
|
||||
# ============================================
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
# 环境检查
|
||||
check_environment()
|
||||
|
||||
from waitress import serve
|
||||
|
||||
# 打印启动信息
|
||||
logger.info("=" * 70)
|
||||
logger.info("🚀 正在启动Whisper语音识别服务...")
|
||||
logger.info("=" * 70)
|
||||
|
||||
# 预加载模型
|
||||
load_whisper_model()
|
||||
|
||||
# 启动信息
|
||||
logger.info("")
|
||||
logger.info("=" * 70)
|
||||
logger.info("✅ Whisper服务启动成功!")
|
||||
logger.info("=" * 70)
|
||||
logger.info(f"📍 本地地址: http://127.0.0.1:5001")
|
||||
logger.info(f"📍 局域网地址: http://0.0.0.0:5001")
|
||||
logger.info(f"📍 访问地址: http://192.168.0.106:5001")
|
||||
logger.info("=" * 70)
|
||||
logger.info(f"⚙️ 运行模式: 多线程并发")
|
||||
logger.info(f"⚙️ Whisper模型: {MODEL_NAME}")
|
||||
logger.info(f"⚙️ 工作线程: 8 个")
|
||||
logger.info(f"⚙️ 并发能力: 40-60 人同时使用")
|
||||
logger.info(f"⚙️ 超时时间: 300 秒")
|
||||
logger.info(f"⚙️ 最大连接: 100 个")
|
||||
logger.info(f"⚙️ 最大文件: 16 MB")
|
||||
logger.info("=" * 70)
|
||||
logger.info("")
|
||||
logger.info("📌 API接口列表:")
|
||||
logger.info(" [GET] /health - 健康检查")
|
||||
logger.info(" [POST] /recognize - 语音识别(只识别)")
|
||||
logger.info(" [POST] /evaluate - 语音评测(识别+评分)")
|
||||
logger.info("=" * 70)
|
||||
logger.info("")
|
||||
logger.info("💡 使用示例:")
|
||||
logger.info(" 健康检查: curl http://192.168.0.106:5001/health")
|
||||
logger.info(" 语音识别: curl -F 'file=@audio.mp3' http://192.168.0.106:5001/recognize")
|
||||
logger.info(" 语音评测: curl -F 'file=@audio.mp3' -F 'text=你好' http://192.168.0.106:5001/evaluate")
|
||||
logger.info("=" * 70)
|
||||
logger.info("")
|
||||
logger.info("✨ 服务已就绪,等待请求...")
|
||||
logger.info("✨ 按 Ctrl+C 停止服务")
|
||||
logger.info("")
|
||||
|
||||
# 使用waitress启动(支持多线程)
|
||||
serve(
|
||||
app,
|
||||
host='0.0.0.0',
|
||||
port=5001,
|
||||
threads=8, # 8个工作线程,支持40-60人并发
|
||||
channel_timeout=300, # 单个请求超时5分钟
|
||||
connection_limit=100, # 最多100个并发连接
|
||||
backlog=64, # 连接队列长度
|
||||
recv_bytes=65536, # 接收缓冲区 64KB
|
||||
send_bytes=65536, # 发送缓冲区 64KB
|
||||
url_scheme='http'
|
||||
)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("")
|
||||
logger.info("=" * 70)
|
||||
logger.info("⏹️ 收到停止信号,正在关闭服务...")
|
||||
logger.info("=" * 70)
|
||||
logger.info("👋 服务已停止")
|
||||
|
||||
except Exception as e:
|
||||
logger.error("=" * 70)
|
||||
logger.error(f"❌ 服务启动失败: {e}")
|
||||
logger.error(traceback.format_exc())
|
||||
logger.error("=" * 70)
|
||||
sys.exit(1)
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
本地Whisper语音识别服务
|
||||
替代百度API,完全离线运行
|
||||
|
||||
安装:
|
||||
pip install openai-whisper flask flask-cors
|
||||
|
||||
运行:
|
||||
python whisper_server.py
|
||||
|
||||
优点:
|
||||
1. 完全免费,无限次调用
|
||||
2. 离线运行,不需要网络
|
||||
3. 识别准确率高(接近百度API)
|
||||
4. 支持中文、英文等多语言
|
||||
"""
|
||||
|
||||
from flask import Flask, request, jsonify
|
||||
from flask_cors import CORS
|
||||
import whisper
|
||||
import os
|
||||
import tempfile
|
||||
import logging
|
||||
|
||||
# 繁简体转换
|
||||
try:
|
||||
import zhconv
|
||||
HAS_ZHCONV = True
|
||||
except ImportError:
|
||||
HAS_ZHCONV = False
|
||||
print("⚠️ 未安装zhconv库,无法进行繁简体转换")
|
||||
print(" 安装命令: pip install zhconv")
|
||||
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# 配置日志
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局变量:Whisper模型
|
||||
whisper_model = None
|
||||
|
||||
def load_whisper_model():
|
||||
"""加载Whisper模型(懒加载)"""
|
||||
global whisper_model
|
||||
if whisper_model is None:
|
||||
logger.info("正在加载Whisper模型...")
|
||||
# 使用base模型(平衡速度和准确度)
|
||||
# 可选: tiny, base, small, medium, large
|
||||
# tiny: 最快,准确度一般
|
||||
# base: 快速,准确度好 ✅ 推荐
|
||||
# small: 较慢,准确度高
|
||||
# medium/large: 很慢,准确度最高
|
||||
whisper_model = whisper.load_model("base")
|
||||
logger.info("✅ Whisper模型加载成功")
|
||||
return whisper_model
|
||||
|
||||
def convert_to_simplified(text):
|
||||
"""
|
||||
将繁体中文转换为简体中文
|
||||
|
||||
参数:
|
||||
text: 待转换的文本
|
||||
|
||||
返回:
|
||||
简体中文文本
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
if HAS_ZHCONV:
|
||||
try:
|
||||
# 使用zhconv进行繁简转换
|
||||
simplified = zhconv.convert(text, 'zh-cn')
|
||||
logger.info(f"繁简转换: {text} -> {simplified}")
|
||||
return simplified
|
||||
except Exception as e:
|
||||
logger.warning(f"繁简转换失败: {e}")
|
||||
return text
|
||||
else:
|
||||
# 如果没有安装zhconv,返回原文
|
||||
return text
|
||||
|
||||
@app.route('/health', methods=['GET'])
|
||||
def health():
|
||||
"""健康检查"""
|
||||
return jsonify({
|
||||
"status": "ok",
|
||||
"service": "Whisper语音识别服务",
|
||||
"model": "base"
|
||||
})
|
||||
|
||||
@app.route('/recognize', methods=['POST'])
|
||||
def recognize():
|
||||
"""
|
||||
语音识别接口
|
||||
|
||||
参数:
|
||||
- file: 音频文件(支持MP3, WAV, M4A等)
|
||||
- language: 语言(可选,默认自动检测)
|
||||
|
||||
返回:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "识别成功",
|
||||
"data": {
|
||||
"text": "识别的文本",
|
||||
"language": "zh",
|
||||
"confidence": 0.95
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
# 检查是否有文件
|
||||
if 'file' not in request.files:
|
||||
return jsonify({
|
||||
"code": 400,
|
||||
"msg": "未找到音频文件",
|
||||
"data": None
|
||||
}), 400
|
||||
|
||||
audio_file = request.files['file']
|
||||
language = request.form.get('language', 'zh') # 默认中文
|
||||
|
||||
# 保存临时文件
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as temp_file:
|
||||
audio_file.save(temp_file.name)
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# 加载模型
|
||||
model = load_whisper_model()
|
||||
|
||||
# 识别音频
|
||||
logger.info(f"开始识别音频: {audio_file.filename}")
|
||||
result = model.transcribe(
|
||||
temp_path,
|
||||
language=language,
|
||||
task='transcribe', # transcribe=识别,translate=翻译成英文
|
||||
fp16=False # CPU模式
|
||||
)
|
||||
|
||||
recognized_text = result['text'].strip()
|
||||
detected_language = result.get('language', language)
|
||||
|
||||
# ✅ 繁体转简体
|
||||
if detected_language == 'zh':
|
||||
recognized_text = convert_to_simplified(recognized_text)
|
||||
|
||||
logger.info(f"✅ 识别成功: {recognized_text}")
|
||||
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "识别成功",
|
||||
"data": {
|
||||
"text": recognized_text,
|
||||
"language": detected_language,
|
||||
"segments": len(result.get('segments', [])),
|
||||
"duration": result.get('duration', 0)
|
||||
}
|
||||
})
|
||||
|
||||
finally:
|
||||
# 删除临时文件
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"识别失败: {str(e)}", exc_info=True)
|
||||
return jsonify({
|
||||
"code": 500,
|
||||
"msg": f"识别失败: {str(e)}",
|
||||
"data": None
|
||||
}), 500
|
||||
|
||||
@app.route('/evaluate', methods=['POST'])
|
||||
def evaluate():
|
||||
"""
|
||||
语音评测接口(完整功能)
|
||||
|
||||
参数:
|
||||
- file: 音频文件
|
||||
- text: 标准文本(用于对比)
|
||||
|
||||
返回:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "评测成功",
|
||||
"data": {
|
||||
"text": "识别的文本",
|
||||
"score": 95,
|
||||
"accuracy": 98,
|
||||
"fluency": 92,
|
||||
"completeness": 95,
|
||||
"pronunciation": 94
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"code": 400, "msg": "未找到音频文件", "data": None}), 400
|
||||
|
||||
audio_file = request.files['file']
|
||||
standard_text = request.form.get('text', '')
|
||||
|
||||
# 保存临时文件
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix='.mp3') as temp_file:
|
||||
audio_file.save(temp_file.name)
|
||||
temp_path = temp_file.name
|
||||
|
||||
try:
|
||||
# 1. 识别音频
|
||||
model = load_whisper_model()
|
||||
result = model.transcribe(temp_path, language='zh', fp16=False)
|
||||
recognized_text = result['text'].strip()
|
||||
|
||||
# ✅ 繁体转简体
|
||||
recognized_text = convert_to_simplified(recognized_text)
|
||||
|
||||
# 2. 计算评分
|
||||
from difflib import SequenceMatcher
|
||||
import re
|
||||
|
||||
# 清理文本:去除所有标点符号和空格,只保留汉字、字母、数字
|
||||
# \w 匹配字母、数字、下划线、汉字
|
||||
clean_recognized = re.sub(r'[^\w]', '', recognized_text, flags=re.UNICODE).lower()
|
||||
clean_standard = re.sub(r'[^\w]', '', standard_text, flags=re.UNICODE).lower()
|
||||
|
||||
# 相似度
|
||||
similarity = SequenceMatcher(None, clean_recognized, clean_standard).ratio()
|
||||
|
||||
# 计算各项评分
|
||||
accuracy = similarity * 100 # 准确度
|
||||
completeness = min(len(clean_recognized) / max(len(clean_standard), 1) * 100, 100) # 完整度
|
||||
fluency = accuracy * 0.95 # 流利度(基于准确度)
|
||||
pronunciation = accuracy * 0.98 # 发音(基于准确度)
|
||||
|
||||
# 总分
|
||||
total_score = (accuracy * 0.3 + completeness * 0.25 + fluency * 0.3 + pronunciation * 0.15)
|
||||
|
||||
logger.info(f"✅ 评测完成: {recognized_text} | 得分: {total_score:.0f}")
|
||||
|
||||
return jsonify({
|
||||
"code": 200,
|
||||
"msg": "评测成功",
|
||||
"data": {
|
||||
"text": recognized_text,
|
||||
"score": round(total_score),
|
||||
"accuracy": round(accuracy),
|
||||
"fluency": round(fluency),
|
||||
"completeness": round(completeness),
|
||||
"pronunciation": round(pronunciation),
|
||||
"similarity": round(similarity * 100, 2)
|
||||
}
|
||||
})
|
||||
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"评测失败: {str(e)}", exc_info=True)
|
||||
return jsonify({"code": 500, "msg": f"评测失败: {str(e)}", "data": None}), 500
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("🎤 本地Whisper语音识别服务")
|
||||
print("=" * 60)
|
||||
print("")
|
||||
print("✅ 优势:")
|
||||
print(" 1. 完全免费,无限次调用")
|
||||
print(" 2. 离线运行,不需要网络")
|
||||
print(" 3. 识别准确率高")
|
||||
print(" 4. 数据完全私有")
|
||||
print("")
|
||||
print("📦 安装依赖:")
|
||||
print(" pip install openai-whisper flask flask-cors")
|
||||
print("")
|
||||
print("🚀 启动服务:")
|
||||
print(" python whisper_server.py")
|
||||
print("")
|
||||
print("📌 API接口:")
|
||||
print(" 健康检查: GET http://localhost:5001/health")
|
||||
print(" 语音识别: POST http://localhost:5001/recognize")
|
||||
print(" 语音评测: POST http://localhost:5001/evaluate")
|
||||
print("")
|
||||
print("=" * 60)
|
||||
print("")
|
||||
|
||||
# 启动服务
|
||||
app.run(host='0.0.0.0', port=5001, debug=True)
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
-- 1️⃣ 先查看要删除的用户(确认范围)
|
||||
SELECT user_id, user_name, nick_name, register_type, create_time
|
||||
FROM sys_user
|
||||
WHERE user_id > 200 AND del_flag = '0'
|
||||
ORDER BY user_id;
|
||||
|
||||
-- 2️⃣ 查看影响范围(统计)
|
||||
SELECT
|
||||
'用户数' AS 项目,
|
||||
COUNT(*) AS 数量
|
||||
FROM sys_user
|
||||
WHERE user_id > 200 AND del_flag = '0'
|
||||
UNION ALL
|
||||
SELECT
|
||||
'学习记录数',
|
||||
COUNT(*)
|
||||
FROM learning_record
|
||||
WHERE student_id > 200
|
||||
UNION ALL
|
||||
SELECT
|
||||
'学习详情数',
|
||||
COUNT(*)
|
||||
FROM learning_detail
|
||||
WHERE student_id > 200
|
||||
UNION ALL
|
||||
SELECT
|
||||
'考试成绩数',
|
||||
COUNT(*)
|
||||
FROM score
|
||||
WHERE student_id > 200
|
||||
UNION ALL
|
||||
SELECT
|
||||
'班级关联数',
|
||||
COUNT(*)
|
||||
FROM student_class
|
||||
WHERE student_id > 200;
|
||||
|
||||
-- ============================================================
|
||||
-- ⚠️ 确认无误后,执行以下删除操作
|
||||
-- ============================================================
|
||||
|
||||
-- 3️⃣ 删除关联数据(按顺序)
|
||||
|
||||
-- 删除学习详情
|
||||
DELETE FROM learning_detail
|
||||
WHERE student_id > 200;
|
||||
|
||||
-- 删除学习记录
|
||||
DELETE FROM learning_record
|
||||
WHERE student_id > 200;
|
||||
|
||||
-- 删除考试成绩
|
||||
DELETE FROM score
|
||||
WHERE student_id > 200;
|
||||
|
||||
-- 删除班级关联
|
||||
DELETE FROM student_class
|
||||
WHERE student_id > 200;
|
||||
|
||||
-- 删除用户角色关联
|
||||
DELETE FROM sys_user_role
|
||||
WHERE user_id > 200;
|
||||
|
||||
-- 删除课程分配(如果用户是学生)
|
||||
DELETE FROM course_assignment
|
||||
WHERE student_id > 200;
|
||||
|
||||
-- 4️⃣ 最后删除用户表记录
|
||||
DELETE FROM sys_user
|
||||
WHERE user_id > 200 AND del_flag = '0';
|
||||
|
||||
-- 5️⃣ 验证删除结果
|
||||
SELECT
|
||||
'剩余用户数' AS 验证项,
|
||||
COUNT(*) AS 数量
|
||||
FROM sys_user
|
||||
WHERE del_flag = '0';
|
||||
|
||||
-- ============================================================
|
||||
-- 💡 如果想使用逻辑删除(推荐,可恢复),使用以下语句代替
|
||||
-- ============================================================
|
||||
|
||||
/*
|
||||
-- 逻辑删除用户(设置 del_flag = '2')
|
||||
UPDATE sys_user
|
||||
SET del_flag = '2',
|
||||
update_time = NOW()
|
||||
WHERE user_id > 200 AND del_flag = '0';
|
||||
|
||||
-- 验证逻辑删除
|
||||
SELECT user_id, user_name, nick_name, del_flag
|
||||
FROM sys_user
|
||||
WHERE user_id > 200;
|
||||
*/
|
||||
55
log/数据库/delete_users_over_200.sql
Normal file
55
log/数据库/delete_users_over_200.sql
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
-- ============================================
|
||||
-- 删除 user_id >= 100 的用户及其关联数据
|
||||
-- 只删除:用户、学习记录、语音评测、课程分配
|
||||
-- 保留:admin(1), ry(2) 等系统用户
|
||||
-- 执行前请先备份数据库!
|
||||
-- ============================================
|
||||
|
||||
-- 关闭外键检查(加快删除速度)
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
-- ============================================
|
||||
-- 1. 删除学习记录
|
||||
-- ============================================
|
||||
|
||||
-- 删除学习详情记录
|
||||
DELETE FROM learning_detail WHERE student_id >= 100;
|
||||
|
||||
-- 删除学习记录
|
||||
DELETE FROM learning_record WHERE student_id >= 100;
|
||||
|
||||
-- ============================================
|
||||
-- 2. 删除语音评测记录
|
||||
-- ============================================
|
||||
|
||||
DELETE FROM voice_evaluation WHERE student_id >= 100;
|
||||
|
||||
-- ============================================
|
||||
-- 3. 删除课程分配(只删除该用户的分配,不影响课程本身)
|
||||
-- ============================================
|
||||
|
||||
DELETE FROM course_assignment WHERE student_id >= 100;
|
||||
|
||||
-- ============================================
|
||||
-- 4. 删除用户
|
||||
-- ============================================
|
||||
|
||||
DELETE FROM sys_user WHERE user_id >= 100;
|
||||
|
||||
-- 恢复外键检查
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- ============================================
|
||||
-- 验证结果
|
||||
-- ============================================
|
||||
|
||||
-- 查看剩余用户数量
|
||||
SELECT COUNT(*) AS '剩余用户数' FROM sys_user;
|
||||
|
||||
-- 查看剩余用户列表
|
||||
SELECT user_id, user_name, nick_name FROM sys_user ORDER BY user_id;
|
||||
|
||||
-- 确认课件未受影响
|
||||
SELECT COUNT(*) AS '课件数量' FROM courseware;
|
||||
SELECT COUNT(*) AS '课程数量' FROM course;
|
||||
SELECT COUNT(*) AS '评测内容数量' FROM voice_evaluation_content;
|
||||
|
|
@ -178,9 +178,9 @@ def main():
|
|||
print("="*60)
|
||||
|
||||
# 配置参数
|
||||
count = 10
|
||||
count = 3200
|
||||
# 生成数据条数
|
||||
start_id = 500 # 起始信息编号
|
||||
start_id = 200 # 起始信息编号
|
||||
output_file = 'test_data.xlsx'
|
||||
|
||||
print(f"配置: 生成 {count} 条数据,起始编号 {start_id}")
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Reference in New Issue
Block a user