perf(图片): 压缩图片大小、img标签启用原生lazy

This commit is contained in:
huangjunsen0406
2025-04-01 00:42:36 +08:00
parent fab4adb07e
commit 0585a0732f
12 changed files with 149 additions and 160 deletions
+1 -1
View File
@@ -28,5 +28,5 @@ nav {
} }
</style> </style>
<script setup lang="ts"> <script>
</script> </script>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 MiB

After

Width:  |  Height:  |  Size: 1.9 MiB

@@ -2,7 +2,7 @@
<el-dialog :visible.sync="visible" width="400px" center> <el-dialog :visible.sync="visible" width="400px" center>
<div style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> <div style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;"> <div style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" /> <img loading="lazy" src="@/assets/home/equipment.png" alt="" style="width: 18px;height: 15px;" />
</div> </div>
添加智慧体 添加智慧体
</div> </div>
@@ -4,7 +4,7 @@
style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;"> style="margin: 0 10px 10px;display: flex;align-items: center;gap: 10px;font-weight: 700;font-size: 20px;text-align: left;color: #3d4566;">
<div <div
style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;"> style="width: 40px;height: 40px;border-radius: 50%;background: #5778ff;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" /> <img loading="lazy" src="@/assets/login/shield.png" alt="" style="width: 19px;height: 23px; filter: brightness(0) invert(1);" />
</div> </div>
修改密码 修改密码
</div> </div>
@@ -3,22 +3,22 @@
<div class="header-container"> <div class="header-container">
<!-- 左侧元素 --> <!-- 左侧元素 -->
<div class="header-left"> <div class="header-left">
<img alt="" src="@/assets/xiaozhi-logo.png" class="logo-img"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" class="logo-img"/>
<img alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" class="brand-img"/>
</div> </div>
<!-- 中间导航菜单 --> <!-- 中间导航菜单 -->
<div class="header-center"> <div class="header-center">
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome"> <div class="equipment-management" :class="{ 'active-tab': $route.path === '/home' }" @click="goHome">
<img alt="" src="@/assets/header/roboot.png" :style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }"/> <img loading="lazy" alt="" src="@/assets/header/roboot.png" :style="{ filter: $route.path === '/home' ? 'brightness(0) invert(1)' : 'None' }"/>
智能体管理 智能体管理
</div> </div>
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/user-management' }" @click="goUserManagement"> <div class="equipment-management" :class="{ 'active-tab': $route.path === '/user-management' }" @click="goUserManagement">
<img alt="" src="@/assets/header/user_management.png" :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }"/> <img loading="lazy" alt="" src="@/assets/header/user_management.png" :style="{ filter: $route.path === '/user-management' ? 'brightness(0) invert(1)' : 'None' }"/>
用户管理 用户管理
</div> </div>
<div class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" @click="goModelConfig"> <div class="equipment-management" :class="{ 'active-tab': $route.path === '/model-config' }" @click="goModelConfig">
<img alt="" src="@/assets/header/model_config.png" :style="{ filter: $route.path === '/model-config' ? 'brightness(0) invert(1)' : 'None' }"/> <img loading="lazy" alt="" src="@/assets/header/model_config.png" :style="{ filter: $route.path === '/model-config' ? 'brightness(0) invert(1)' : 'None' }"/>
模型配置 模型配置
</div> </div>
</div> </div>
@@ -35,7 +35,7 @@
<i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i> <i slot="suffix" class="el-icon-search search-icon" @click="handleSearch"></i>
</el-input> </el-input>
</div> </div>
<img alt="" src="@/assets/home/avatar.png" class="avatar-img"/> <img loading="lazy" alt="" src="@/assets/home/avatar.png" class="avatar-img"/>
<el-dropdown trigger="click" class="user-dropdown"> <el-dropdown trigger="click" class="user-dropdown">
<span class="el-dropdown-link"> <span class="el-dropdown-link">
{{ userInfo.mobile || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i> {{ userInfo.mobile || '加载中...' }}<i class="el-icon-arrow-down el-icon--right"></i>
+2 -2
View File
@@ -9,11 +9,11 @@
语音设置 语音设置
</el-button> </el-button>
<el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;"> <el-button plain size="small" @click="handleImport" style="background: #7b9de5; color: white;">
<img alt="" src="@/assets/model/inner_conf.png"> <img loading="lazy" alt="" src="@/assets/model/inner_conf.png">
导入配置 导入配置
</el-button> </el-button>
<el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;"> <el-button plain size="small" @click="handleExport" style="background: #71c9d1; color: white;">
<img alt="" src="@/assets/model/output_conf.png"> <img loading="lazy" alt="" src="@/assets/model/output_conf.png">
导出配置 导出配置
</el-button> </el-button>
</div> </div>
+7 -7
View File
@@ -4,15 +4,15 @@
<el-header> <el-header>
<div <div
style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;"> style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div> </div>
</el-header> </el-header>
<el-main style="position: relative;"> <el-main style="position: relative;">
<div class="login-box"> <div class="login-box">
<div <div
style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;"> style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/> <img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
<div class="login-text">登录</div> <div class="login-text">登录</div>
<div class="login-welcome"> <div class="login-welcome">
WELCOME TO LOGIN WELCOME TO LOGIN
@@ -20,19 +20,19 @@
</div> </div>
<div style="padding: 0 30px;"> <div style="padding: 0 30px;">
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/username.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
<el-input v-model="form.username" placeholder="请输入用户名"/> <el-input v-model="form.username" placeholder="请输入用户名"/>
</div> </div>
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/password.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.password" placeholder="请输入密码" type="password"/> <el-input v-model="form.password" placeholder="请输入密码" type="password"/>
</div> </div>
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;"> <div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;"> <div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img alt="" class="input-icon" src="@/assets/login/shield.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/> <el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
</div> </div>
<img v-if="captchaUrl" <img loading="lazy" v-if="captchaUrl"
:src="captchaUrl" :src="captchaUrl"
alt="验证码" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" style="width: 150px; height: 40px; cursor: pointer;"
+8 -8
View File
@@ -4,8 +4,8 @@
<!-- 保持相同的头部 --> <!-- 保持相同的头部 -->
<el-header> <el-header>
<div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;"> <div style="display: flex;align-items: center;margin-top: 15px;margin-left: 10px;gap: 10px;">
<img alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-logo.png" style="width: 45px;height: 45px;"/>
<img alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/> <img loading="lazy" alt="" src="@/assets/xiaozhi-ai.png" style="width: 70px;height: 13px;"/>
</div> </div>
</el-header> </el-header>
@@ -13,7 +13,7 @@
<div class="login-box"> <div class="login-box">
<!-- 修改标题部分 --> <!-- 修改标题部分 -->
<div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;"> <div style="display: flex;align-items: center;gap: 20px;margin-bottom: 39px;padding: 0 30px;">
<img alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/> <img loading="lazy" alt="" src="@/assets/login/hi.png" style="width: 34px;height: 34px;"/>
<div class="login-text">注册</div> <div class="login-text">注册</div>
<div class="login-welcome"> <div class="login-welcome">
WELCOME TO REGISTER WELCOME TO REGISTER
@@ -23,29 +23,29 @@
<div style="padding: 0 30px;"> <div style="padding: 0 30px;">
<!-- 用户名输入框 --> <!-- 用户名输入框 -->
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/username.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/username.png"/>
<el-input v-model="form.username" placeholder="请输入用户名"/> <el-input v-model="form.username" placeholder="请输入用户名"/>
</div> </div>
<!-- 密码输入框 --> <!-- 密码输入框 -->
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/password.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.password" placeholder="请输入密码" type="password"/> <el-input v-model="form.password" placeholder="请输入密码" type="password"/>
</div> </div>
<!-- 新增确认密码 --> <!-- 新增确认密码 -->
<div class="input-box"> <div class="input-box">
<img alt="" class="input-icon" src="@/assets/login/password.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/password.png"/>
<el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/> <el-input v-model="form.confirmPassword" placeholder="请确认密码" type="password"/>
</div> </div>
<!-- 验证码部分保持相同 --> <!-- 验证码部分保持相同 -->
<div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;"> <div style="display: flex; align-items: center; margin-top: 20px; width: 100%; gap: 10px;">
<div class="input-box" style="width: calc(100% - 130px); margin-top: 0;"> <div class="input-box" style="width: calc(100% - 130px); margin-top: 0;">
<img alt="" class="input-icon" src="@/assets/login/shield.png"/> <img loading="lazy" alt="" class="input-icon" src="@/assets/login/shield.png"/>
<el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/> <el-input v-model="form.captcha" placeholder="请输入验证码" style="flex: 1;"/>
</div> </div>
<img v-if="captchaUrl" <img loading="lazy" v-if="captchaUrl"
:src="captchaUrl" :src="captchaUrl"
alt="验证码" alt="验证码"
style="width: 150px; height: 40px; cursor: pointer;" style="width: 150px; height: 40px; cursor: pointer;"
+2 -2
View File
@@ -7,7 +7,7 @@
style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;"> style="padding: 15px 24px;font-weight: 700;font-size: 19px;text-align: left;color: #3d4566;display: flex;gap: 13px;align-items: center;">
<div <div
style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;"> style="width: 37px;height: 37px;background: #5778ff;border-radius: 50%;display: flex;align-items: center;justify-content: center;">
<img src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/> <img loading="lazy" src="@/assets/home/setting-user.png" alt="" style="width: 19px;height: 19px;"/>
</div> </div>
{{ form.agentName }} {{ form.agentName }}
</div> </div>
@@ -83,7 +83,7 @@
重制 重制
</div> </div>
<div class="clear-text"> <div class="clear-text">
<img src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/> <img loading="lazy" src="@/assets/home/red-info.png" alt="" style="width: 19px;height: 19px;"/>
保存配置后需要重启设备新的配置才会生效 保存配置后需要重启设备新的配置才会生效
</div> </div>
</div> </div>
+121 -132
View File
@@ -18,70 +18,68 @@
</template> </template>
<script> <script>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import Recorder from 'opus-recorder'; import Recorder from 'opus-recorder';
import { OpusDecoder } from 'opus-decoder'; import { OpusDecoder } from 'opus-decoder';
export default { export default {
name: 'TestPage', name: 'TestPage',
setup() { data() {
const messages = ref([]); return {
const chatContainer = ref(null); messages: [],
const wsStatus = ref('disconnected'); wsStatus: 'disconnected',
const isRecording = ref(false); isRecording: false,
let ws = null; ws: null,
let recorder = null; recorder: null,
let stream = null; stream: null,
let audioContext = null; audioContext: null,
let sourceNode = null; sourceNode: null,
let audioDecoder = null; audioDecoder: null,
let audioBufferQueue = []; // 当前句子的音频缓冲区 audioBufferQueue: [], // 当前句子的音频缓冲区
let playbackQueue = []; // 播放队列,存储待播放的句子 playbackQueue: [], // 播放队列,存储待播放的句子
let isPlaying = false; isPlaying: false,
}
const connectWebSocket = () => { },
ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址 methods: {
ws.binaryType = 'arraybuffer'; connectWebSocket() {
ws.onopen = () => { this.ws = new WebSocket('ws://192.168.3.97:8000');//修改服务端地址
wsStatus.value = 'connected'; this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.wsStatus = 'connected';
console.log('WebSocket 连接成功'); console.log('WebSocket 连接成功');
ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' })); this.ws.send(JSON.stringify({ type: 'auth', 'device-id': 'test-device' }));
audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 }); this.audioDecoder = new OpusDecoder({ sampleRate: 16000, channels: 1 });
}; };
ws.onmessage = async (event) => { this.ws.onmessage = async (event) => {
if (typeof event.data === 'string') { if (typeof event.data === 'string') {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
console.log('收到文本消息:', msg); console.log('收到文本消息:', msg);
if (msg.type === 'stt') { if (msg.type === 'stt') {
messages.value.push({ role: 'user', content: msg.text }); this.messages.push({ role: 'user', content: msg.text });
scrollToBottom(); this.scrollToBottom();
} else if (msg.type === 'llm') { } else if (msg.type === 'llm') {
messages.value.push({ role: 'assistant', content: msg.text }); this.messages.push({ role: 'assistant', content: msg.text });
scrollToBottom(); this.scrollToBottom();
} else if (msg.type === 'tts') { } else if (msg.type === 'tts') {
if (msg.state === 'sentence_start') { if (msg.state === 'sentence_start') {
// 开始新句子,清空当前缓冲区 this.audioBufferQueue = [];
audioBufferQueue = []; const lastMessage = this.messages[this.messages.length - 1];
const lastMessage = messages.value[messages.value.length - 1];
if (!lastMessage || lastMessage.content !== msg.text) { if (!lastMessage || lastMessage.content !== msg.text) {
messages.value.push({ role: 'assistant', content: msg.text }); this.messages.push({ role: 'assistant', content: msg.text });
scrollToBottom(); this.scrollToBottom();
} }
} else if (msg.state === 'sentence_end') { } else if (msg.state === 'sentence_end') {
// 句子结束,将当前缓冲区加入播放队列 if (this.audioBufferQueue.length > 0) {
if (audioBufferQueue.length > 0) { this.playbackQueue.push([...this.audioBufferQueue]);
playbackQueue.push([...audioBufferQueue]); this.audioBufferQueue = [];
audioBufferQueue = []; this.playNextInQueue();
playNextInQueue(); // 尝试播放队列中的下一句
} }
} else if (msg.state === 'stop') { } else if (msg.state === 'stop') {
console.log('TTS 任务结束'); console.log('TTS 任务结束');
// 确保所有剩余音频播放 if (this.audioBufferQueue.length > 0) {
if (audioBufferQueue.length > 0) { this.playbackQueue.push([...this.audioBufferQueue]);
playbackQueue.push([...audioBufferQueue]); this.audioBufferQueue = [];
audioBufferQueue = []; this.playNextInQueue();
playNextInQueue();
} }
} }
} else if (msg.type === 'hello') { } else if (msg.type === 'hello') {
@@ -96,18 +94,17 @@ export default {
console.log('音频帧前8字节:', frameHead); console.log('音频帧前8字节:', frameHead);
try { try {
const decoded = audioDecoder.decodeFrame(opusFrame); const decoded = this.audioDecoder.decodeFrame(opusFrame);
console.log('解码结果:', decoded); console.log('解码结果:', decoded);
if (decoded && decoded.channelData && decoded.channelData[0]) { if (decoded && decoded.channelData && decoded.channelData[0]) {
const pcmData = decoded.channelData[0]; const pcmData = decoded.channelData[0];
if (pcmData.length > 0) { if (pcmData.length > 0) {
audioBufferQueue.push(pcmData); this.audioBufferQueue.push(pcmData);
console.log('解码音频帧,PCM 数据长度:', pcmData.length); console.log('解码音频帧,PCM 数据长度:', pcmData.length);
// 如果缓冲区达到一定长度(例如 5 帧,180ms),立即播放 if (this.audioBufferQueue.length >= 5 && this.playbackQueue.length === 0 && !this.isPlaying) {
if (audioBufferQueue.length >= 5 && playbackQueue.length === 0 && !isPlaying) { this.playbackQueue.push([...this.audioBufferQueue]);
playbackQueue.push([...audioBufferQueue]); this.audioBufferQueue = [];
audioBufferQueue = []; this.playNextInQueue();
playNextInQueue();
} }
} else { } else {
console.warn('解码成功,但 PCM 数据长度为 0'); console.warn('解码成功,但 PCM 数据长度为 0');
@@ -120,39 +117,39 @@ export default {
} }
} }
}; };
ws.onerror = (error) => { this.ws.onerror = (error) => {
console.error('WebSocket 错误:', error); console.error('WebSocket 错误:', error);
wsStatus.value = 'error'; this.wsStatus = 'error';
}; };
ws.onclose = () => { this.ws.onclose = () => {
wsStatus.value = 'disconnected'; this.wsStatus = 'disconnected';
console.log('WebSocket 断开'); console.log('WebSocket 断开');
setTimeout(connectWebSocket, 1000); setTimeout(this.connectWebSocket, 1000);
}; };
}; },
scrollToBottom() {
const scrollToBottom = () => { this.$nextTick(() => {
nextTick(() => { if (this.$refs.chatContainer) {
if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight; this.$refs.chatContainer.scrollTop = this.$refs.chatContainer.scrollHeight;
}
}); });
}; },
async playNextInQueue() {
const playNextInQueue = async () => { if (this.isPlaying || this.playbackQueue.length === 0) {
if (isPlaying || playbackQueue.length === 0) { return;
return; // 正在播放或队列为空,等待下次触发
} }
isPlaying = true; this.isPlaying = true;
if (!audioContext || audioContext.state === 'closed') { if (!this.audioContext || this.audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
} }
const pcmBuffers = playbackQueue.shift(); // 取出队列中的第一句 const pcmBuffers = this.playbackQueue.shift();
const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0); const totalLength = pcmBuffers.reduce((sum, pcm) => sum + pcm.length, 0);
if (totalLength === 0) { if (totalLength === 0) {
console.error('音频缓冲区总长度为 0,无法播放'); console.error('音频缓冲区总长度为 0,无法播放');
isPlaying = false; this.isPlaying = false;
playNextInQueue(); // 尝试播放下一句 this.playNextInQueue();
return; return;
} }
@@ -163,22 +160,21 @@ export default {
offset += pcm.length; offset += pcm.length;
} }
const audioBuffer = audioContext.createBuffer(1, totalLength, 16000); const audioBuffer = this.audioContext.createBuffer(1, totalLength, 16000);
audioBuffer.getChannelData(0).set(mergedPcm); audioBuffer.getChannelData(0).set(mergedPcm);
const source = audioContext.createBufferSource(); const source = this.audioContext.createBufferSource();
source.buffer = audioBuffer; source.buffer = audioBuffer;
source.connect(audioContext.destination); source.connect(this.audioContext.destination);
source.onended = () => { source.onended = () => {
isPlaying = false; this.isPlaying = false;
console.log('音频播放结束'); console.log('音频播放结束');
playNextInQueue(); // 播放结束后继续下一句 this.playNextInQueue();
}; };
source.start(); source.start();
console.log('开始播放音频,总长度:', totalLength); console.log('开始播放音频,总长度:', totalLength);
}; },
stripOggContainer(data) {
const stripOggContainer = (data) => {
let arrayBuffer; let arrayBuffer;
if (data instanceof ArrayBuffer) { if (data instanceof ArrayBuffer) {
arrayBuffer = data; arrayBuffer = data;
@@ -227,23 +223,22 @@ export default {
} }
console.log('剥离后找到', frames.length, '个裸 Opus 帧'); console.log('剥离后找到', frames.length, '个裸 Opus 帧');
return frames; return frames;
}; },
async initRecorder() {
const initRecorder = async () => {
console.log('开始初始化录音'); console.log('开始初始化录音');
try { try {
if (stream) { if (this.stream) {
stream.getTracks().forEach(track => track.stop()); this.stream.getTracks().forEach(track => track.stop());
} }
stream = await navigator.mediaDevices.getUserMedia({ audio: true }); this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
console.log('获取麦克风权限成功,stream:', stream); console.log('获取麦克风权限成功,stream:', this.stream);
if (!audioContext || audioContext.state === 'closed') { if (!this.audioContext || this.audioContext.state === 'closed') {
audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 }); this.audioContext = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
} }
sourceNode = audioContext.createMediaStreamSource(stream); this.sourceNode = this.audioContext.createMediaStreamSource(this.stream);
recorder = new Recorder({ this.recorder = new Recorder({
encoderPath: '/encoderWorker.min.js', encoderPath: '/encoderWorker.min.js',
sampleRate: 16000, sampleRate: 16000,
numberOfChannels: 1, numberOfChannels: 1,
@@ -252,17 +247,16 @@ export default {
encoderFrameSize: 60, encoderFrameSize: 60,
encoderBitRate: 24000, encoderBitRate: 24000,
monitorGain: 0, monitorGain: 0,
sourceNode: sourceNode, sourceNode: this.sourceNode,
ogg: false, ogg: false,
streamPages: false, streamPages: false,
maxFramesPerPage: 1, maxFramesPerPage: 1,
}); });
recorder.ondataavailable = (data) => { this.recorder.ondataavailable = (data) => {
console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name); console.log('录音数据可用:', data.byteLength, '类型:', data.constructor.name);
const frames = stripOggContainer(data); const frames = this.stripOggContainer(data);
frames.forEach((frame, index) => { frames.forEach((frame, index) => {
const frameView = new DataView(frame);
const frameHead = Array.from(new Uint8Array(frame.slice(0, 8))) const frameHead = Array.from(new Uint8Array(frame.slice(0, 8)))
.map(b => b.toString(16).padStart(2, '0')) .map(b => b.toString(16).padStart(2, '0'))
.join(' '); .join(' ');
@@ -273,8 +267,8 @@ export default {
return; return;
} }
if (ws && ws.readyState === WebSocket.OPEN) { if (this.ws && this.ws.readyState === WebSocket.OPEN) {
ws.send(frame); this.ws.send(frame);
console.log('发送裸 Opus 帧:', frame.byteLength); console.log('发送裸 Opus 帧:', frame.byteLength);
} else { } else {
console.warn('WebSocket 未连接,跳过发送'); console.warn('WebSocket 未连接,跳过发送');
@@ -282,16 +276,16 @@ export default {
}); });
}; };
recorder.onstart = () => { this.recorder.onstart = () => {
console.log('录音已启动'); console.log('录音已启动');
}; };
recorder.onstop = () => { this.recorder.onstop = () => {
console.log('录音停止'); console.log('录音停止');
stream.getTracks().forEach(track => track.stop()); this.stream.getTracks().forEach(track => track.stop());
stream = null; this.stream = null;
sourceNode = null; this.sourceNode = null;
scrollToBottom(); this.scrollToBottom();
}; };
console.log('Recorder 初始化成功'); console.log('Recorder 初始化成功');
@@ -299,48 +293,43 @@ export default {
console.error('初始化录音失败:', err); console.error('初始化录音失败:', err);
alert('无法访问麦克风或录音初始化失败,请检查权限'); alert('无法访问麦克风或录音初始化失败,请检查权限');
} }
}; },
async toggleRecording() {
const toggleRecording = async () => { console.log('点击 toggleRecording,当前状态:', this.isRecording, 'WebSocket 状态:', this.wsStatus);
console.log('点击 toggleRecording,当前状态:', isRecording.value, 'WebSocket 状态:', wsStatus.value); if (!this.recorder) {
if (!recorder) { await this.initRecorder();
await initRecorder(); if (!this.recorder) {
if (!recorder) {
console.error('recorder 初始化失败'); console.error('recorder 初始化失败');
return; return;
} }
} }
if (isRecording.value) { if (this.isRecording) {
console.log('停止录音'); console.log('停止录音');
recorder.stop(); this.recorder.stop();
isRecording.value = false; this.isRecording = false;
} else { } else {
try { try {
console.log('开始录音'); console.log('开始录音');
await initRecorder(); await this.initRecorder();
await recorder.start(); await this.recorder.start();
console.log('录音开始后,状态:', recorder.state); console.log('录音开始后,状态:', this.recorder.state);
isRecording.value = true; this.isRecording = true;
} catch (err) { } catch (err) {
console.error('录音启动失败:', err); console.error('录音启动失败:', err);
} }
} }
}; },
},
onMounted(() => { mounted() {
console.log('组件挂载,初始化 WebSocket'); console.log('组件挂载,初始化 WebSocket');
connectWebSocket(); this.connectWebSocket();
}); },
destroyed() {
onUnmounted(() => { if (this.ws) this.ws.close();
if (ws) ws.close(); if (this.stream) this.stream.getTracks().forEach(track => track.stop());
if (stream) stream.getTracks().forEach(track => track.stop()); if (this.recorder) this.recorder.stop();
if (recorder) recorder.stop(); if (this.audioContext) this.audioContext.close();
if (audioContext) audioContext.close(); if (this.audioDecoder) this.audioDecoder.destroy();
if (audioDecoder) audioDecoder.destroy();
});
return { messages, chatContainer, wsStatus, isRecording, toggleRecording };
} }
}; };
</script> </script>