This commit is contained in:
2025-09-06 09:39:19 +08:00
parent 28f7f3bc41
commit 3a1a6cc037
7 changed files with 502 additions and 3 deletions

View File

@@ -19,7 +19,7 @@ sys.path.append(os.path.join(os.path.dirname(__file__), ".."))
from Config.Config import XF_APPID, XF_APIKEY, XF_APISECRET
@router.post("/save-audio")
async def save_audio(audio: UploadFile = File(...), txt: str = Form(...)):
async def save_audio(audio: UploadFile = File(...), txt: str = Form(...), language: str = Form("english")): # 添加语言参数,默认英文
"""保存音频文件并评分"""
temp_file = None
try:
@@ -59,8 +59,8 @@ async def save_audio(audio: UploadFile = File(...), txt: str = Form(...)):
appid=XF_APPID,
api_key=XF_APIKEY,
api_secret=XF_APISECRET,
audio_file=mp3_temp_file, # 使用转换后的mp3文件
language="english",
audio_file=mp3_temp_file,
language=language, # 使用动态参数
txt=txt
)
results, eval_time = evaluator.run_evaluation()

View File

@@ -0,0 +1,499 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>中文朗读评测</title>
<style>
/* 完整CSS样式恢复 */
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; background: linear-gradient(135deg, #0f172a, #1e293b); min-height: 100vh; display: flex; justify-content: center; align-items: center; padding: 20px; color: #e2e8f0; }
.container { width: 100%; max-width: 800px; background: rgba(30, 41, 59, 0.8); backdrop-filter: blur(10px); border-radius: 20px; padding: 30px; box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3); }
h1 { text-align: center; margin-bottom: 30px; color: #5eead4; text-shadow: 0 0 10px rgba(94, 234, 212, 0.3); }
.visualizer { width: 100%; height: 120px; background: rgba(15, 23, 42, 0.5); border-radius: 10px; margin: 20px 0; position: relative; overflow: hidden; }
#visualizerCanvas { width: 100%; height: 100%; }
.timer { text-align: center; font-size: 24px; margin: 10px 0; color: #e2e8f0; }
.status { text-align: center; padding: 10px; border-radius: 8px; margin: 15px 0; background: rgba(79, 70, 229, 0.2); color: #94a3b8; transition: all 0.3s ease; }
.status.recording { background: rgba(239, 68, 68, 0.2); color: #fecdd3; }
.status.success { background: rgba(34, 197, 94, 0.2); color: #dcfce7; }
.status.error { background: rgba(239, 68, 68, 0.2); color: #fecdd3; }
.status.uploading { background: rgba(250, 204, 21, 0.2); color: #fef3c7; }
.controls { display: flex; gap: 10px; justify-content: center; margin: 20px 0; flex-wrap: wrap; }
.btn-record, .btn-stop, .btn-play, .btn-upload {
display: flex; align-items: center; gap: 8px; padding: 12px 24px;
border: none; border-radius: 8px; cursor: pointer; font-size: 16px;
transition: all 0.2s ease; box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.btn-record { background: #ef4444; color: white; }
.btn-record:hover { background: #dc2626; transform: translateY(-2px); }
.btn-stop { background: #64748b; color: white; }
.btn-stop:disabled { background: #334155; cursor: not-allowed; }
.btn-play { background: #10b981; color: white; }
.btn-play:disabled { background: #166534; cursor: not-allowed; }
.btn-upload { background: #3b82f6; color: white; }
.btn-upload:disabled { background: #1e40af; cursor: not-allowed; }
.audio-container { margin: 20px 0; padding: 20px; background: rgba(15, 23, 42, 0.3); border-radius: 10px; display: none; }
.audio-container.show { display: block; animation: fadeIn 0.5s ease; }
#audioPlayer { width: 100%; margin-bottom: 10px; }
.upload-progress { margin: 20px 0; height: 30px; display: none; }
.upload-progress.show { display: block; }
.progress-bar { height: 8px; background: rgba(15, 23, 42, 0.5); border-radius: 4px; overflow: hidden; }
.progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #60a5fa); width: 0%; transition: width 0.3s ease; }
.progress-text { margin-top: 8px; color: #94a3b8; font-size: 14px; }
.evaluation-container { margin-top: 30px; padding: 20px; background: rgba(15, 23, 42, 0.3); border-radius: 10px; display: none; }
.evaluation-container.show { display: block; animation: fadeIn 0.5s ease; }
.score-card { display: flex; justify-content: space-around; flex-wrap: wrap; gap: 15px; margin-bottom: 25px; }
.score-item { text-align: center; min-width: 80px; }
.score-value { display: block; font-size: 32px; font-weight: bold; margin-bottom: 5px; color: #5eead4; }
.score-label { color: #94a3b8; font-size: 14px; }
.words-list { margin-top: 20px; }
.word-item { display: flex; justify-content: space-between; padding: 8px 12px; border-bottom: 1px solid rgba(148, 163, 184, 0.1); }
.word-content { color: #e2e8f0; }
.word-score { font-weight: bold; }
.text-container { margin-bottom: 25px; }
textarea { min-height: 100px; resize: vertical; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@media (max-width: 600px) {
.container { padding: 15px; }
.controls { flex-direction: column; }
.btn-record, .btn-stop, .btn-play, .btn-upload { width: 100%; justify-content: center; }
}
</style>
</head>
<body>
<div class="container">
<h1>🎙️ 中文朗读评测</h1>
<!-- 添加中文朗读文本输入区域 -->
<div class="text-container">
<h3 style="color: #5eead4; margin-bottom: 10px;">📝 请朗读以下文本</h3>
<textarea id="readingText" rows="4" style="width: 100%; padding: 12px; border-radius: 8px; background: rgba(15, 23, 42, 0.7); border: 1px solid rgba(94, 234, 212, 0.2); color: #e2e8f0; font-size: 16px; resize: vertical; font-family: inherit;">大家好!很高兴认识你们。今天天气很好,我正在使用这个工具练习中文发音。</textarea>
</div>
<div class="visualizer">
<canvas id="visualizerCanvas"></canvas>
</div>
<div class="timer" id="timer">00:00</div>
<div class="status" id="status">准备就绪</div>
<div class="controls">
<button id="recordBtn" class="btn-record">
<span>🔴</span> 开始录音
</button>
<button id="stopBtn" class="btn-stop" disabled>
<span>⏹️</span> 停止录音
</button>
<button id="playBtn" class="btn-play" disabled>
<span>▶️</span> 播放
</button>
<button id="uploadBtn" class="btn-upload" disabled>
<span>📤</span> 上传到服务器
</button>
</div>
<div class="audio-container" id="audioContainer">
<audio id="audioPlayer" controls></audio>
<p style="text-align: center; color: #666; margin-top: 10px;">
录制时间: <span id="duration">0</span> 秒 |
文件大小: <span id="fileSize">0</span> KB
</p>
</div>
<div class="upload-progress" id="uploadProgress">
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="progress-text" id="progressText">上传进度: 0%</div>
</div>
<!-- 评分结果显示区域 -->
<div class="evaluation-container" id="evaluationContainer">
<h3 style="color: #5eead4; margin-bottom: 15px;">📊 评测结果</h3>
<div class="score-card">
<div class="score-item">
<span class="score-value" id="totalScore">--</span>
<span class="score-label">总分</span>
</div>
<div class="score-item">
<span class="score-value" id="accuracyScore">--</span>
<span class="score-label">准确度</span>
</div>
<div class="score-item">
<span class="score-value" id="fluencyScore">--</span>
<span class="score-label">流利度</span>
</div>
<div class="score-item">
<span class="score-value" id="completenessScore">--</span>
<span class="score-label">完整度</span>
</div>
</div>
<div class="words-list" id="wordsList">
<h4 style="color: #5eead4; margin-bottom: 10px;">字词评分</h4>
<div id="wordsContent">等待评测结果...</div>
</div>
</div>
</div>
<script>
class AudioRecorder {
constructor() {
this.mediaRecorder = null;
this.audioChunks = [];
this.recordedBlob = null;
this.isRecording = false;
this.startTime = null;
this.timerInterval = null;
this.audioContext = null;
this.analyser = null;
this.dataArray = null;
this.canvas = document.getElementById('visualizerCanvas');
this.canvasCtx = this.canvas.getContext('2d');
this.animationId = null;
this.initializeElements();
this.setupEventListeners();
this.setupCanvas();
}
initializeElements() {
this.recordBtn = document.getElementById('recordBtn');
this.stopBtn = document.getElementById('stopBtn');
this.playBtn = document.getElementById('playBtn');
this.uploadBtn = document.getElementById('uploadBtn');
this.status = document.getElementById('status');
this.audioPlayer = document.getElementById('audioPlayer');
this.audioContainer = document.getElementById('audioContainer');
this.timer = document.getElementById('timer');
this.duration = document.getElementById('duration');
this.fileSize = document.getElementById('fileSize');
this.audioFormat = "WebM";
this.uploadProgress = document.getElementById('uploadProgress');
this.progressFill = document.getElementById('progressFill');
this.progressText = document.getElementById('progressText');
this.evaluationContainer = document.getElementById('evaluationContainer');
this.totalScore = document.getElementById('totalScore');
this.accuracyScore = document.getElementById('accuracyScore');
this.fluencyScore = document.getElementById('fluencyScore');
this.completenessScore = document.getElementById('completenessScore');
this.wordsList = document.getElementById('wordsList');
this.wordsContent = document.getElementById('wordsContent');
}
setupCanvas() {
const resizeCanvas = () => {
this.canvas.width = this.canvas.offsetWidth;
this.canvas.height = this.canvas.offsetHeight;
};
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
}
setupEventListeners() {
this.recordBtn.addEventListener('click', () => this.startRecording());
this.stopBtn.addEventListener('click', () => this.stopRecording());
this.playBtn.addEventListener('click', () => this.playRecording());
this.uploadBtn.addEventListener('click', () => this.uploadRecording());
}
async startRecording() {
try {
this.updateStatus('正在请求麦克风权限...', 'recording');
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 44100
}
});
this.setupAudioContext(stream);
const mimeType = this.getMimeType();
this.mediaRecorder = new MediaRecorder(stream, {
mimeType: mimeType,
audioBitsPerSecond: 128000
});
this.audioChunks = [];
this.mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
this.audioChunks.push(event.data);
}
};
this.mediaRecorder.onstop = () => {
this.recordedBlob = new Blob(this.audioChunks, {type: mimeType});
this.onRecordingComplete();
};
this.mediaRecorder.onstart = () => {
this.isRecording = true;
this.startTime = Date.now();
this.startTimer();
this.startVisualization();
this.updateButtons();
};
this.mediaRecorder.start(1000);
} catch (error) {
console.error('录音错误:', error);
this.updateStatus('无法访问麦克风,请检查权限设置', 'error');
alert('录音初始化失败: ' + error.message);
}
}
setupAudioContext(stream) {
try {
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
const source = this.audioContext.createMediaStreamSource(stream);
this.analyser = this.audioContext.createAnalyser();
this.analyser.fftSize = 256;
const bufferLength = this.analyser.frequencyBinCount;
this.dataArray = new Uint8Array(bufferLength);
source.connect(this.analyser);
} catch (error) {
console.warn('音频可视化初始化失败:', error);
}
}
startVisualization() {
if (!this.analyser) return;
const draw = () => {
if (!this.isRecording) return;
this.animationId = requestAnimationFrame(draw);
this.analyser.getByteFrequencyData(this.dataArray);
this.canvasCtx.fillStyle = 'rgba(15, 23, 42, 0.7)';
this.canvasCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
const barWidth = (this.canvas.width / this.dataArray.length) * 2.5;
let barHeight;
let x = 0;
for (let i = 0; i < this.dataArray.length; i++) {
barHeight = (this.dataArray[i] / 255) * this.canvas.height * 0.8;
const gradient = this.canvasCtx.createLinearGradient(0, this.canvas.height - barHeight, 0, this.canvas.height);
gradient.addColorStop(0, '#5eead4');
gradient.addColorStop(1, '#0ea5e9');
this.canvasCtx.fillStyle = gradient;
this.canvasCtx.fillRect(x, this.canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
};
draw();
}
stopVisualization() {
if (this.animationId) {
cancelAnimationFrame(this.animationId);
this.animationId = null;
}
this.canvasCtx.fillStyle = 'rgba(15, 23, 42, 0.7)';
this.canvasCtx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
getMimeType() {
const supportedTypes = {
'webm': 'audio/webm;codecs=opus',
'mp4': 'audio/mp4',
'wav': 'audio/wav'
};
return supportedTypes['webm'];
}
stopRecording() {
if (this.mediaRecorder && this.isRecording) {
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
this.isRecording = false;
this.stopTimer();
this.stopVisualization();
this.updateStatus('录音已停止', 'stopped');
this.updateButtons();
}
}
onRecordingComplete() {
const url = URL.createObjectURL(this.recordedBlob);
this.audioPlayer.src = url;
const duration = Math.round((Date.now() - this.startTime) / 1000);
const sizeInKB = Math.round(this.recordedBlob.size / 1024);
this.duration.textContent = duration;
this.fileSize.textContent = sizeInKB;
this.audioContainer.classList.add('show');
this.updateStatus(`录音完成!时长: ${duration}秒, 大小: ${sizeInKB}KB`, 'success');
this.updateButtons();
}
playRecording() {
if (this.audioPlayer.src) {
this.audioPlayer.play();
this.updateStatus('正在播放录音...', 'success');
}
}
async uploadRecording() {
if (!this.recordedBlob) return;
this.updateStatus('正在上传录音...', 'uploading');
this.uploadProgress.classList.add('show');
const formData = new FormData();
const fileName = `recording_${Date.now()}.webm`;
formData.append('audio', this.recordedBlob, fileName);
formData.append('txt', document.getElementById('readingText').value);
formData.append('language', 'chinese'); // 添加中文语言参数
try {
this.simulateUploadProgress();
const response = await fetch('/api/xunFei/save-audio', {
method: 'POST',
body: formData,
headers: {'Accept': 'application/json'}
});
if (!response.ok) {
throw new Error(`上传失败: ${response.status} ${response.statusText}`);
}
const result = await response.json();
this.uploadProgress.classList.remove('show');
if (result.success) {
this.updateStatus('评测完成!', 'success');
this.displayEvaluationResults(result.evaluation);
console.log('评测结果:', result.evaluation);
} else {
throw new Error(result.error || '未知错误');
}
} catch (error) {
console.error('上传错误:', error);
this.uploadProgress.classList.remove('show');
this.updateStatus('上传失败: ' + error.message, 'error');
alert('上传失败: ' + error.message);
}
}
displayEvaluationResults(evaluation) {
if (!evaluation) return;
// 显示评分卡片
this.totalScore.textContent = evaluation.total_score ? evaluation.total_score.toFixed(1) : '--';
this.accuracyScore.textContent = evaluation.accuracy_score ? evaluation.accuracy_score.toFixed(1) : '--';
this.fluencyScore.textContent = evaluation.fluency_score ? evaluation.fluency_score.toFixed(1) : '--';
this.completenessScore.textContent = evaluation.completeness_score ? evaluation.completeness_score.toFixed(1) : '--';
this.evaluationContainer.classList.add('show');
// 显示字词评分
if (evaluation.words && evaluation.words.length > 0) {
let wordsHTML = '';
evaluation.words.forEach((word, index) => {
const score = word.total_score ? word.total_score.toFixed(1) : '0.0';
const color = score >= 80 ? '#10b981' : score >= 60 ? '#f59e0b' : '#ef4444';
wordsHTML += `
<div class="word-item">
<span class="word-content">${word.content || `字词${index + 1}`}</span>
<span class="word-score" style="color: ${color}">${score}</span>
</div>
`;
});
this.wordsContent.innerHTML = wordsHTML;
} else {
this.wordsContent.innerHTML = '<p style="color: #cbd5e1; text-align: center;">无字词评分数据</p>';
}
}
simulateUploadProgress() {
let progress = 0;
const interval = setInterval(() => {
progress += Math.random() * 15;
if (progress >= 90) {
progress = 90;
clearInterval(interval);
return;
}
this.updateProgress(progress);
}, 200);
}
updateProgress(progress) {
progress = Math.min(100, Math.max(0, progress));
this.progressFill.style.width = progress + '%';
this.progressText.textContent = `上传进度: ${Math.round(progress)}%`;
}
startTimer() {
this.timerInterval = setInterval(() => {
if (this.startTime) {
const elapsed = Date.now() - this.startTime;
const minutes = Math.floor(elapsed / 60000);
const seconds = Math.floor((elapsed % 60000) / 1000);
this.timer.textContent = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
}
}, 100);
}
stopTimer() {
if (this.timerInterval) {
clearInterval(this.timerInterval);
this.timerInterval = null;
}
}
updateButtons() {
this.recordBtn.disabled = this.isRecording;
this.stopBtn.disabled = !this.isRecording;
this.playBtn.disabled = !this.recordedBlob || this.isRecording;
this.uploadBtn.disabled = !this.recordedBlob || this.isRecording;
}
updateStatus(message, type = '') {
this.status.textContent = message;
this.status.className = 'status ' + type;
}
cleanup() {
this.stopTimer();
this.stopVisualization();
if (this.mediaRecorder) {
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
if (this.audioContext) {
this.audioContext.close();
}
if (this.audioPlayer.src) {
URL.revokeObjectURL(this.audioPlayer.src);
}
}
}
const recorder = new AudioRecorder();
window.addEventListener('beforeunload', () => recorder.cleanup());
</script>
</body>
</html>