|
|
@@ -0,0 +1,523 @@
|
|
|
+<template>
|
|
|
+ <!-- 诗词展示区域 - 使用现成的 PoemDisplay 组件 -->
|
|
|
+ <PoemDisplay :content="content" />
|
|
|
+
|
|
|
+ <!-- 录音控制区域 -->
|
|
|
+ <div class="recording-area">
|
|
|
+ <!-- 录音按钮 -->
|
|
|
+ <button
|
|
|
+ @click="toggleRecording"
|
|
|
+ class="microphone-btn"
|
|
|
+ :class="{
|
|
|
+ 'recording': isRecording,
|
|
|
+ 'loading': isEvaluating,
|
|
|
+ 'disabled': isEvaluating
|
|
|
+ }"
|
|
|
+ :disabled="isEvaluating"
|
|
|
+ >
|
|
|
+ <div class="waveform-container" v-if="isRecording">
|
|
|
+ <LiveWaveform
|
|
|
+ style="min-width: 40px;"
|
|
|
+ :active="isRecording"
|
|
|
+ :processing="false"
|
|
|
+ :height="20"
|
|
|
+ :barWidth="2"
|
|
|
+ :barGap="1"
|
|
|
+ :barRadius="1"
|
|
|
+ :sensitivity="1.2"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ <div class="loading-ring" v-else-if="isEvaluating"></div>
|
|
|
+ <svg v-else class="mic-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
|
+ <path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/>
|
|
|
+ <path d="M19 10v2a7 7 0 0 1-14 0v-2"/>
|
|
|
+ <line x1="12" y1="19" x2="12" y2="23"/>
|
|
|
+ <line x1="8" y1="23" x2="16" y2="23"/>
|
|
|
+ </svg>
|
|
|
+ <span class="btn-text">{{ buttonText }}</span>
|
|
|
+ </button>
|
|
|
+
|
|
|
+
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 评价结果展示 -->
|
|
|
+ <div class="evaluation-result" v-if="evaluationResult">
|
|
|
+ <div class="result-header">
|
|
|
+ <span class="result-title">朗读评价</span>
|
|
|
+ <div class="result-score" :class="scoreLevel">{{ evaluationResult.score }}分</div>
|
|
|
+ </div>
|
|
|
+ <div class="result-content">
|
|
|
+ <p class="result-text">{{ displayedResult }}</p>
|
|
|
+ </div>
|
|
|
+ <audio
|
|
|
+ v-if="evaluationResult.audioUrl"
|
|
|
+ :src="evaluationResult.audioUrl"
|
|
|
+ controls
|
|
|
+ class="result-audio"
|
|
|
+ @ended="handleAudioEnded"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup>
|
|
|
+import { ref, computed, onUnmounted } from 'vue';
|
|
|
+import { ElMessage } from 'element-plus';
|
|
|
+import LiveWaveform from '../../../ai/voice/LiveWaveform.vue';
|
|
|
+import PoemDisplay from './PoemDisplay.vue';
|
|
|
+import { evaluateReading } from '@/api/audio.js';
|
|
|
+import { useAudioPlayer } from '@/api/tts/useAudioPlayer.js';
|
|
|
+// 音频播放器钩子
|
|
|
+const { playAudioChunk } = useAudioPlayer();
|
|
|
+
|
|
|
+const props = defineProps({
|
|
|
+ content: {
|
|
|
+ type: String,
|
|
|
+ default: ''
|
|
|
+ }
|
|
|
+});
|
|
|
+
|
|
|
+const emit = defineEmits(['evaluationComplete']);
|
|
|
+
|
|
|
+// 状态管理
|
|
|
+const isRecording = ref(false);
|
|
|
+const isEvaluating = ref(false);
|
|
|
+const evaluationResult = ref(null);
|
|
|
+const displayedResult = ref('');
|
|
|
+
|
|
|
+// 录音相关
|
|
|
+const mediaRecorder = ref(null);
|
|
|
+const mediaStream = ref(null);
|
|
|
+const audioChunks = ref([]);
|
|
|
+const recordedBlob = ref(null);
|
|
|
+
|
|
|
+// 流式请求相关
|
|
|
+const abortController = ref(null);
|
|
|
+
|
|
|
+// 按钮文本
|
|
|
+const buttonText = computed(() => {
|
|
|
+ if (isEvaluating.value) return '处理中...';
|
|
|
+ if (isRecording.value) return '点击停止';
|
|
|
+ return '开始录音';
|
|
|
+});
|
|
|
+
|
|
|
+// 分数等级
|
|
|
+const scoreLevel = computed(() => {
|
|
|
+ if (!evaluationResult.value) return '';
|
|
|
+ const score = evaluationResult.value.score;
|
|
|
+ if (score >= 90) return 'excellent';
|
|
|
+ if (score >= 80) return 'good';
|
|
|
+ if (score >= 60) return 'pass';
|
|
|
+ return 'fail';
|
|
|
+});
|
|
|
+
|
|
|
+// 切换录音状态
|
|
|
+const toggleRecording = async () => {
|
|
|
+ if (isRecording.value) {
|
|
|
+ stopRecording();
|
|
|
+ } else {
|
|
|
+ await startRecording();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 解锁浏览器音频自动播放限制
|
|
|
+const unlockAudio = async () => {
|
|
|
+ try {
|
|
|
+ const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
+ const oscillator = ctx.createOscillator();
|
|
|
+ const gainNode = ctx.createGain();
|
|
|
+
|
|
|
+ oscillator.connect(gainNode);
|
|
|
+ gainNode.connect(ctx.destination);
|
|
|
+
|
|
|
+ gainNode.gain.value = 0;
|
|
|
+ oscillator.frequency.value = 440;
|
|
|
+ oscillator.start();
|
|
|
+ oscillator.stop(ctx.currentTime + 0.01);
|
|
|
+
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
+ ctx.close();
|
|
|
+
|
|
|
+ console.log('[音频解锁] 浏览器音频已解锁');
|
|
|
+ } catch (e) {
|
|
|
+ console.log('[音频解锁] 解锁失败:', e);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 开始录音
|
|
|
+const startRecording = async () => {
|
|
|
+ try {
|
|
|
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
|
|
+ ElMessage.error('当前浏览器不支持录音功能');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ await unlockAudio();
|
|
|
+
|
|
|
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
|
+ mediaStream.value = stream;
|
|
|
+
|
|
|
+ const preferredTypes = [
|
|
|
+ 'audio/wav',
|
|
|
+ 'audio/mp3',
|
|
|
+ 'audio/aac',
|
|
|
+ 'audio/flac',
|
|
|
+ 'audio/webm;codecs=opus',
|
|
|
+ 'audio/webm',
|
|
|
+ 'audio/ogg;codecs=opus',
|
|
|
+ 'audio/ogg'
|
|
|
+ ];
|
|
|
+
|
|
|
+ const supportedTypes = preferredTypes.filter(type => window.MediaRecorder.isTypeSupported(type));
|
|
|
+
|
|
|
+ if (supportedTypes.length === 0) {
|
|
|
+ ElMessage.error('当前浏览器不支持音频录制');
|
|
|
+ stream.getTracks().forEach(track => track.stop());
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const options = { mimeType: supportedTypes[0] };
|
|
|
+ mediaRecorder.value = new MediaRecorder(stream, options);
|
|
|
+
|
|
|
+ mediaRecorder.value.ondataavailable = (event) => {
|
|
|
+ if (event.data.size > 0) {
|
|
|
+ audioChunks.value.push(event.data);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ mediaRecorder.value.onstop = async () => {
|
|
|
+ const recordedBlobTemp = new Blob(audioChunks.value, { type: mediaRecorder.value.mimeType });
|
|
|
+ audioChunks.value = [];
|
|
|
+
|
|
|
+ const mimeType = mediaRecorder.value.mimeType;
|
|
|
+ // 如果录制的不是WAV格式,转换为WAV(后端支持的格式)
|
|
|
+ if (!mimeType.startsWith('audio/wav')) {
|
|
|
+ try {
|
|
|
+ recordedBlob.value = await convertToWav(recordedBlobTemp);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('音频格式转换失败:', error);
|
|
|
+ ElMessage.error('音频格式转换失败,请重试');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ recordedBlob.value = recordedBlobTemp;
|
|
|
+ }
|
|
|
+
|
|
|
+ convertBlobToBase64AndEvaluate();
|
|
|
+ };
|
|
|
+
|
|
|
+ mediaRecorder.value.start(100);
|
|
|
+ isRecording.value = true;
|
|
|
+
|
|
|
+ } catch (error) {
|
|
|
+ console.error('录音启动失败:', error);
|
|
|
+ ElMessage.error('录音失败,请检查麦克风权限');
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 停止录音
|
|
|
+const stopRecording = () => {
|
|
|
+ if (mediaRecorder.value && mediaRecorder.value.state !== 'inactive') {
|
|
|
+ mediaRecorder.value.stop();
|
|
|
+ }
|
|
|
+ if (mediaStream.value) {
|
|
|
+ mediaStream.value.getTracks().forEach(track => track.stop());
|
|
|
+ mediaStream.value = null;
|
|
|
+ }
|
|
|
+ isRecording.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 将Blob转换为Base64并调用评价接口
|
|
|
+const convertBlobToBase64AndEvaluate = async () => {
|
|
|
+ if (!recordedBlob.value) return;
|
|
|
+
|
|
|
+ isEvaluating.value = true;
|
|
|
+ displayedResult.value = '';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const base64Audio = await blobToBase64(recordedBlob.value);
|
|
|
+ abortController.value = await evaluateReading(base64Audio, handleStreamData);
|
|
|
+ } catch (error) {
|
|
|
+ console.error('评价请求失败:', error);
|
|
|
+ ElMessage.error('评价请求失败,请重试');
|
|
|
+ resetState();
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 将音频转换为WAV格式
|
|
|
+const convertToWav = async (blob) => {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
+ const reader = new FileReader();
|
|
|
+
|
|
|
+ reader.onload = async (e) => {
|
|
|
+ try {
|
|
|
+ const arrayBuffer = e.target.result;
|
|
|
+ const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
|
|
|
+ const wavBuffer = encodeAudioBufferToWav(audioBuffer);
|
|
|
+ const wavBlob = new Blob([wavBuffer], { type: 'audio/wav' });
|
|
|
+ audioContext.close();
|
|
|
+ resolve(wavBlob);
|
|
|
+ } catch (error) {
|
|
|
+ audioContext.close();
|
|
|
+ reject(error);
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ reader.onerror = () => {
|
|
|
+ audioContext.close();
|
|
|
+ reject(new Error('读取音频失败'));
|
|
|
+ };
|
|
|
+
|
|
|
+ reader.readAsArrayBuffer(blob);
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 将AudioBuffer编码为WAV格式
|
|
|
+const encodeAudioBufferToWav = (audioBuffer) => {
|
|
|
+ const sampleRate = audioBuffer.sampleRate;
|
|
|
+ const channels = audioBuffer.numberOfChannels;
|
|
|
+ const length = audioBuffer.length * channels * 2 + 44;
|
|
|
+ const buffer = new ArrayBuffer(length);
|
|
|
+ const view = new DataView(buffer);
|
|
|
+
|
|
|
+ // WAV header
|
|
|
+ writeString(view, 0, 'RIFF');
|
|
|
+ view.setUint32(4, length - 8, true);
|
|
|
+ writeString(view, 8, 'WAVE');
|
|
|
+ writeString(view, 12, 'fmt ');
|
|
|
+ view.setUint32(16, 16, true);
|
|
|
+ view.setUint16(20, 1, true);
|
|
|
+ view.setUint16(22, channels, true);
|
|
|
+ view.setUint32(24, sampleRate, true);
|
|
|
+ view.setUint32(28, sampleRate * channels * 2, true);
|
|
|
+ view.setUint16(32, channels * 2, true);
|
|
|
+ view.setUint16(34, 16, true);
|
|
|
+ writeString(view, 36, 'data');
|
|
|
+ view.setUint32(40, length - 44, true);
|
|
|
+
|
|
|
+ // 写入音频数据
|
|
|
+ let offset = 44;
|
|
|
+ for (let i = 0; i < audioBuffer.length; i++) {
|
|
|
+ for (let channel = 0; channel < channels; channel++) {
|
|
|
+ const sample = Math.max(-1, Math.min(1, audioBuffer.getChannelData(channel)[i]));
|
|
|
+ view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
|
|
|
+ offset += 2;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return buffer;
|
|
|
+};
|
|
|
+
|
|
|
+// 辅助函数:写入字符串到DataView
|
|
|
+const writeString = (view, offset, string) => {
|
|
|
+ for (let i = 0; i < string.length; i++) {
|
|
|
+ view.setUint8(offset + i, string.charCodeAt(i));
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// Blob转Base64
|
|
|
+const blobToBase64 = (blob) => {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onloadend = () => resolve(reader.result);
|
|
|
+ reader.onerror = reject;
|
|
|
+ reader.readAsDataURL(blob);
|
|
|
+ });
|
|
|
+};
|
|
|
+
|
|
|
+// 处理流式数据
|
|
|
+const handleStreamData = async (data) => {
|
|
|
+ if (data.eventType === 'TEXT') {
|
|
|
+ displayedResult.value += data.content || '';
|
|
|
+ } else if (data.eventType === 'AUDIO') {
|
|
|
+ // 使用统一的音频播放器播放
|
|
|
+ await playAudioChunk(data.audioUrl);
|
|
|
+
|
|
|
+ // 保存音频URL用于界面播放器
|
|
|
+ if (!evaluationResult.value) {
|
|
|
+ evaluationResult.value = {};
|
|
|
+ }
|
|
|
+ evaluationResult.value.audioUrl = `data:audio/wav;base64,${data.audioUrl}`;
|
|
|
+ } else if (data.eventType === 'COMPLETE') {
|
|
|
+ isEvaluating.value = false;
|
|
|
+
|
|
|
+ if (!evaluationResult.value) {
|
|
|
+ evaluationResult.value = {};
|
|
|
+ }
|
|
|
+ evaluationResult.value.score = data.score;
|
|
|
+ evaluationResult.value.content = displayedResult.value;
|
|
|
+
|
|
|
+ emit('evaluationComplete', evaluationResult.value);
|
|
|
+ }
|
|
|
+};
|
|
|
+
|
|
|
+// 处理音频播放完成
|
|
|
+const handleAudioEnded = () => {
|
|
|
+ // 音频播放完成后的处理
|
|
|
+};
|
|
|
+
|
|
|
+// 重置状态
|
|
|
+const resetState = () => {
|
|
|
+ isEvaluating.value = false;
|
|
|
+};
|
|
|
+
|
|
|
+// 组件卸载时清理
|
|
|
+onUnmounted(() => {
|
|
|
+ stopRecording();
|
|
|
+ if (abortController.value) {
|
|
|
+ abortController.value.abort();
|
|
|
+ }
|
|
|
+});
|
|
|
+</script>
|
|
|
+
|
|
|
+<style scoped lang="scss">
|
|
|
+@use "sass:math";
|
|
|
+
|
|
|
+@function rpx($px) {
|
|
|
+ @return math.div($px, 750) * 100vw;
|
|
|
+}
|
|
|
+
|
|
|
+.recording-area {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ gap: rpx(15);
|
|
|
+ margin-bottom: rpx(60);
|
|
|
+}
|
|
|
+
|
|
|
+.microphone-btn {
|
|
|
+ width: rpx(80);
|
|
|
+ height: rpx(80);
|
|
|
+ border-radius: 50%;
|
|
|
+ border: 2px solid #ffce1b;
|
|
|
+ background: #fff;
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ align-items: center;
|
|
|
+ justify-content: center;
|
|
|
+ gap: rpx(3);
|
|
|
+ cursor: pointer;
|
|
|
+ transition: all 0.3s ease;
|
|
|
+ box-shadow: 0 3px 10px rgba(255, 206, 27, 0.3);
|
|
|
+
|
|
|
+ &:hover:not(.disabled) {
|
|
|
+ transform: scale(1.05);
|
|
|
+ box-shadow: 0 4px 15px rgba(255, 206, 27, 0.4);
|
|
|
+ }
|
|
|
+
|
|
|
+ &.recording {
|
|
|
+ background: #ffeeba;
|
|
|
+ border-color: #dc3545;
|
|
|
+ animation: pulse 1.5s ease-in-out infinite;
|
|
|
+
|
|
|
+ .mic-icon {
|
|
|
+ color: #dc3545;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ &.loading, &.disabled {
|
|
|
+ opacity: 0.7;
|
|
|
+ cursor: not-allowed;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes pulse {
|
|
|
+ 0%, 100% {
|
|
|
+ box-shadow: 0 0 0 0 rgba(220, 53, 69, 0.4);
|
|
|
+ }
|
|
|
+ 50% {
|
|
|
+ box-shadow: 0 0 0 rpx(10) rgba(220, 53, 69, 0);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.mic-icon {
|
|
|
+ width: rpx(28);
|
|
|
+ height: rpx(28);
|
|
|
+ color: #ffce1b;
|
|
|
+ transition: color 0.3s;
|
|
|
+}
|
|
|
+
|
|
|
+.btn-text {
|
|
|
+ font-size: rpx(12);
|
|
|
+ color: #666;
|
|
|
+}
|
|
|
+
|
|
|
+.waveform-container {
|
|
|
+ width: rpx(50);
|
|
|
+}
|
|
|
+
|
|
|
+.loading-ring {
|
|
|
+ width: rpx(30);
|
|
|
+ height: rpx(30);
|
|
|
+ border: 3px solid #ffce1b;
|
|
|
+ border-top-color: transparent;
|
|
|
+ border-radius: 50%;
|
|
|
+ animation: spin 0.8s linear infinite;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes spin {
|
|
|
+ to { transform: rotate(360deg); }
|
|
|
+}
|
|
|
+
|
|
|
+.evaluation-result {
|
|
|
+ margin-top: rpx(30);
|
|
|
+ padding: rpx(25);
|
|
|
+ background: rgba(255, 255, 255, 0.95);
|
|
|
+ border-radius: rpx(15);
|
|
|
+ box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
|
|
+ width: rpx(550);
|
|
|
+ animation: resultFadeIn 0.5s ease-out;
|
|
|
+}
|
|
|
+
|
|
|
+@keyframes resultFadeIn {
|
|
|
+ from {
|
|
|
+ opacity: 0;
|
|
|
+ transform: translateY(20px);
|
|
|
+ }
|
|
|
+ to {
|
|
|
+ opacity: 1;
|
|
|
+ transform: translateY(0);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+.result-header {
|
|
|
+ display: flex;
|
|
|
+ justify-content: space-between;
|
|
|
+ align-items: center;
|
|
|
+ margin-bottom: rpx(20);
|
|
|
+ padding-bottom: rpx(15);
|
|
|
+ border-bottom: 1px solid #eee;
|
|
|
+}
|
|
|
+
|
|
|
+.result-title {
|
|
|
+ font-size: rpx(20);
|
|
|
+ font-weight: bold;
|
|
|
+ color: #333;
|
|
|
+}
|
|
|
+
|
|
|
+.result-score {
|
|
|
+ font-size: rpx(36);
|
|
|
+ font-weight: bold;
|
|
|
+
|
|
|
+ &.excellent { color: #28a745; }
|
|
|
+ &.good { color: #17a2b8; }
|
|
|
+ &.pass { color: #ffce1b; }
|
|
|
+ &.fail { color: #dc3545; }
|
|
|
+}
|
|
|
+
|
|
|
+.result-content {
|
|
|
+ margin-bottom: rpx(20);
|
|
|
+}
|
|
|
+
|
|
|
+.result-text {
|
|
|
+ font-size: rpx(18);
|
|
|
+ color: #555;
|
|
|
+ line-height: 1.8;
|
|
|
+}
|
|
|
+
|
|
|
+.result-audio {
|
|
|
+ width: 100%;
|
|
|
+ height: rpx(40);
|
|
|
+ border-radius: rpx(20);
|
|
|
+}
|
|
|
+</style>
|