2408 lines
95 KiB
HTML
2408 lines
95 KiB
HTML
<!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>
|
||
body {
|
||
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||
margin: 0;
|
||
padding: 20px;
|
||
background-color: #f5f5f5;
|
||
}
|
||
|
||
.container {
|
||
max-width: 800px;
|
||
margin: 0 auto;
|
||
background-color: white;
|
||
border-radius: 10px;
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||
padding: 10px 20px 10px 20px;
|
||
}
|
||
|
||
h1 {
|
||
color: #333;
|
||
text-align: center;
|
||
margin-bottom: 30px;
|
||
}
|
||
|
||
.section {
|
||
margin-bottom: 20px;
|
||
padding: 15px;
|
||
border-radius: 8px;
|
||
background-color: #f9f9f9;
|
||
}
|
||
|
||
.section h2 {
|
||
margin-top: 0;
|
||
color: #444;
|
||
font-size: 18px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 0;
|
||
}
|
||
|
||
.section h2 .toggle-button {
|
||
margin-left: auto;
|
||
padding: 4px 12px;
|
||
font-size: 12px;
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
height: 28px;
|
||
line-height: 20px;
|
||
}
|
||
|
||
.device-info {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
margin-left: 20px;
|
||
padding: 0 15px;
|
||
background-color: #f9f9f9;
|
||
border-radius: 4px;
|
||
height: 28px;
|
||
line-height: 28px;
|
||
}
|
||
|
||
.device-info span {
|
||
color: #666;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.device-info strong {
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.config-panel {
|
||
display: none;
|
||
transition: all 0.3s ease;
|
||
margin-top: 5px;
|
||
padding: 5px 0;
|
||
}
|
||
|
||
.config-panel.expanded {
|
||
display: block;
|
||
}
|
||
|
||
.control-panel {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
button {
|
||
padding: 8px 15px;
|
||
border: none;
|
||
border-radius: 5px;
|
||
background-color: #4285f4;
|
||
color: white;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
button:hover {
|
||
background-color: #3367d6;
|
||
}
|
||
|
||
button:disabled {
|
||
background-color: #cccccc;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
#serverUrl,
|
||
#otaUrl {
|
||
flex-grow: 1;
|
||
padding: 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
.message-input {
|
||
display: flex;
|
||
gap: 10px;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
#messageInput {
|
||
flex-grow: 1;
|
||
padding: 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
#nfcCardId {
|
||
flex-grow: 1;
|
||
padding: 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
.conversation {
|
||
max-height: 300px;
|
||
overflow-y: auto;
|
||
border: 1px solid #ddd;
|
||
border-radius: 5px;
|
||
padding: 10px;
|
||
background-color: white;
|
||
flex: 1;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.message {
|
||
margin-bottom: 10px;
|
||
padding: 8px 12px;
|
||
border-radius: 8px;
|
||
max-width: 80%;
|
||
}
|
||
|
||
.user {
|
||
background-color: #e2f2ff;
|
||
margin-left: auto;
|
||
margin-right: 10px;
|
||
text-align: right;
|
||
}
|
||
|
||
.server {
|
||
background-color: #f0f0f0;
|
||
margin-right: auto;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.status {
|
||
color: #666;
|
||
font-style: italic;
|
||
font-size: 14px;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
.audio-controls {
|
||
display: flex;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
margin-top: 20px;
|
||
}
|
||
|
||
.audio-visualizer {
|
||
height: 60px;
|
||
width: 100%;
|
||
margin-top: 10px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 5px;
|
||
background-color: #fafafa;
|
||
}
|
||
|
||
.record-button {
|
||
background-color: #db4437;
|
||
}
|
||
|
||
.record-button:hover {
|
||
background-color: #c53929;
|
||
}
|
||
|
||
.record-button.recording {
|
||
animation: pulse 1.5s infinite;
|
||
}
|
||
|
||
@keyframes pulse {
|
||
0% {
|
||
background-color: #db4437;
|
||
}
|
||
|
||
50% {
|
||
background-color: #ff6659;
|
||
}
|
||
|
||
100% {
|
||
background-color: #db4437;
|
||
}
|
||
}
|
||
|
||
#logContainer {
|
||
margin-top: 0;
|
||
padding: 10px;
|
||
background-color: #f0f0f0;
|
||
border-radius: 5px;
|
||
font-family: monospace;
|
||
height: 300px;
|
||
overflow-y: auto;
|
||
flex: 1;
|
||
margin-left: 10px;
|
||
}
|
||
|
||
.log-entry {
|
||
margin: 5px 0;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.log-info {
|
||
color: #333;
|
||
}
|
||
|
||
.log-error {
|
||
color: #db4437;
|
||
}
|
||
|
||
.log-success {
|
||
color: #0f9d58;
|
||
}
|
||
|
||
@keyframes fadeIn {
|
||
from {
|
||
opacity: 0;
|
||
transform: translate(-50%, -60%);
|
||
}
|
||
|
||
to {
|
||
opacity: 1;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
}
|
||
|
||
.script-status {
|
||
display: inline-block;
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
margin-right: 5px;
|
||
}
|
||
|
||
.script-loaded {
|
||
background-color: #0f9d58;
|
||
}
|
||
|
||
.script-loading {
|
||
background-color: #f4b400;
|
||
}
|
||
|
||
.script-error {
|
||
background-color: #db4437;
|
||
}
|
||
|
||
.script-list {
|
||
margin: 10px 0;
|
||
padding: 10px;
|
||
background-color: #f9f9f9;
|
||
border-radius: 5px;
|
||
font-family: monospace;
|
||
font-size: 11px;
|
||
}
|
||
|
||
#scriptStatus.success {
|
||
background-color: #e6f4ea;
|
||
color: #0f9d58;
|
||
border-left: 4px solid #0f9d58;
|
||
}
|
||
|
||
#scriptStatus.error {
|
||
background-color: #fce8e6;
|
||
color: #db4437;
|
||
border-left: 4px solid #db4437;
|
||
}
|
||
|
||
#scriptStatus.warning {
|
||
background-color: #fef7e0;
|
||
color: #f4b400;
|
||
border-left: 4px solid #f4b400;
|
||
}
|
||
|
||
/* 标签页样式 */
|
||
.tabs {
|
||
display: flex;
|
||
margin-bottom: 20px;
|
||
border-bottom: 2px solid #e0e0e0;
|
||
}
|
||
|
||
.tab {
|
||
padding: 10px 20px;
|
||
cursor: pointer;
|
||
border: none;
|
||
background: none;
|
||
font-size: 16px;
|
||
color: #666;
|
||
position: relative;
|
||
transition: all 0.3s ease;
|
||
}
|
||
|
||
.tab:hover {
|
||
color: #4285f4;
|
||
}
|
||
|
||
.tab.active {
|
||
color: #4285f4;
|
||
font-weight: bold;
|
||
}
|
||
|
||
.tab.active::after {
|
||
content: '';
|
||
position: absolute;
|
||
bottom: -2px;
|
||
left: 0;
|
||
width: 100%;
|
||
height: 2px;
|
||
background-color: #4285f4;
|
||
}
|
||
|
||
.tab-content {
|
||
display: none;
|
||
}
|
||
|
||
.tab-content.active {
|
||
display: block;
|
||
}
|
||
|
||
.flex-container {
|
||
display: flex;
|
||
gap: 20px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.config-item {
|
||
display: flex;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
width: 100%;
|
||
}
|
||
|
||
.config-item label {
|
||
width: 100px;
|
||
text-align: right;
|
||
margin-right: 10px;
|
||
color: #666;
|
||
}
|
||
|
||
.config-item input {
|
||
flex-grow: 1;
|
||
padding: 6px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 5px;
|
||
}
|
||
|
||
.control-panel {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.connection-controls {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
width: 100%;
|
||
}
|
||
|
||
.connection-controls input {
|
||
flex: 1;
|
||
padding: 8px;
|
||
border: 1px solid #ddd;
|
||
border-radius: 5px;
|
||
min-width: 200px;
|
||
}
|
||
|
||
.connection-controls button {
|
||
white-space: nowrap;
|
||
padding: 8px 15px;
|
||
}
|
||
|
||
.connection-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 20px;
|
||
margin-left: 20px;
|
||
padding: 0 15px;
|
||
background-color: #f9f9f9;
|
||
border-radius: 4px;
|
||
height: 28px;
|
||
line-height: 28px;
|
||
}
|
||
|
||
.connection-status span {
|
||
color: #666;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.connection-status .status {
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
</style>
|
||
</head>
|
||
|
||
<body>
|
||
<div class="container">
|
||
<h1>小智服务器测试页面</h1>
|
||
|
||
<div id="scriptStatus" style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);">
|
||
正在加载Opus库...</div>
|
||
|
||
<!-- 添加配置面板 -->
|
||
<div class="section">
|
||
<h2>
|
||
设备配置
|
||
<span class="device-info">
|
||
<span>MAC: <strong id="displayMac">00:11:22:33:44:55</strong></span>
|
||
<span>客户端: <strong id="displayClient">web_test_client</strong></span>
|
||
</span>
|
||
<button class="toggle-button" id="toggleConfig">编辑</button>
|
||
</h2>
|
||
<div class="config-panel" id="configPanel">
|
||
<div class="control-panel">
|
||
<div class="config-item">
|
||
<label for="deviceMac">设备MAC:</label>
|
||
<input type="text" id="deviceMac" value="00:11:22:33:44:55" placeholder="设备MAC地址">
|
||
</div>
|
||
<div class="config-item">
|
||
<label for="deviceName">设备名称:</label>
|
||
<input type="text" id="deviceName" value="Web测试设备" placeholder="设备名称">
|
||
</div>
|
||
<div class="config-item">
|
||
<label for="clientId">客户端ID:</label>
|
||
<input type="text" id="clientId" value="web_test_client" placeholder="客户端ID">
|
||
</div>
|
||
<div class="config-item">
|
||
<label for="token">认证Token:</label>
|
||
<input type="text" id="token" value="your-token1" placeholder="认证Token">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>
|
||
连接信息
|
||
<span class="connection-status">
|
||
<span>OTA: <span id="otaStatus" class="status">ota未连接</span></span>
|
||
<span>WS: <span id="connectionStatus" class="status">ws未连接</span></span>
|
||
</span>
|
||
</h2>
|
||
<div class="connection-controls">
|
||
<input type="text" id="otaUrl" value="http://127.0.0.1:8002/xiaozhi/ota/" placeholder="OTA服务器地址" />
|
||
<input type="text" id="serverUrl" value="ws://127.0.0.1:8000/xiaozhi/v1/"
|
||
placeholder="WebSocket服务器地址" />
|
||
<button id="connectButton">连接</button>
|
||
<button id="authTestButton">测试认证</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<div class="tabs">
|
||
<button class="tab active" data-tab="text">文本消息</button>
|
||
<button class="tab" data-tab="voice">语音消息</button>
|
||
</div>
|
||
|
||
<div class="tab-content active" id="textTab">
|
||
<div class="message-input">
|
||
<input type="text" id="messageInput" placeholder="输入消息..." disabled>
|
||
<button id="sendTextButton" disabled>发送</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="tab-content" id="voiceTab">
|
||
<div class="audio-controls">
|
||
<button id="recordButton" class="record-button" disabled>开始录音</button>
|
||
</div>
|
||
<canvas id="audioVisualizer" class="audio-visualizer"></canvas>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="section">
|
||
<h2>会话记录</h2>
|
||
<div class="flex-container">
|
||
<div id="conversation" class="conversation"></div>
|
||
<div id="logContainer">
|
||
<div class="log-entry log-info">准备就绪,请连接服务器开始测试...</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Opus解码库 -->
|
||
<script src="libopus.js"></script>
|
||
|
||
<script>
|
||
// 需要加载的脚本列表 - 移除Opus依赖
|
||
const scriptFiles = [];
|
||
|
||
// 脚本加载状态
|
||
const scriptStatus = {
|
||
loading: 0,
|
||
loaded: 0,
|
||
failed: 0,
|
||
total: scriptFiles.length
|
||
};
|
||
|
||
// 检查Opus库是否已加载
|
||
function checkOpusLoaded() {
|
||
try {
|
||
// 检查Module是否存在(本地库导出的全局变量)
|
||
if (typeof Module === 'undefined') {
|
||
throw new Error('Opus库未加载,Module对象不存在');
|
||
}
|
||
|
||
// 尝试先使用Module.instance(libopus.js最后一行导出方式)
|
||
if (typeof Module.instance !== 'undefined' && typeof Module.instance._opus_decoder_get_size === 'function') {
|
||
// 使用Module.instance对象替换全局Module对象
|
||
window.ModuleInstance = Module.instance;
|
||
log('Opus库加载成功(使用Module.instance)', 'success');
|
||
updateScriptStatus('Opus库加载成功', 'success');
|
||
|
||
// 3秒后隐藏状态
|
||
const statusElement = document.getElementById('scriptStatus');
|
||
if (statusElement) statusElement.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
// 如果没有Module.instance,检查全局Module函数
|
||
if (typeof Module._opus_decoder_get_size === 'function') {
|
||
window.ModuleInstance = Module;
|
||
log('Opus库加载成功(使用全局Module)', 'success');
|
||
updateScriptStatus('Opus库加载成功', 'success');
|
||
|
||
// 3秒后隐藏状态
|
||
const statusElement = document.getElementById('scriptStatus');
|
||
if (statusElement) statusElement.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
throw new Error('Opus解码函数未找到,可能Module结构不正确');
|
||
} catch (err) {
|
||
log(`Opus库加载失败,请检查libopus.js文件是否存在且正确: ${err.message}`, 'error');
|
||
updateScriptStatus('Opus库加载失败,请检查libopus.js文件是否存在且正确', 'error');
|
||
}
|
||
}
|
||
|
||
// 更新脚本状态显示
|
||
function updateScriptStatus(message, type) {
|
||
const statusElement = document.getElementById('scriptStatus');
|
||
if (statusElement) {
|
||
statusElement.textContent = message;
|
||
statusElement.className = `script-status ${type}`;
|
||
statusElement.style.display = 'block';
|
||
statusElement.style.width = 'auto';
|
||
}
|
||
}
|
||
|
||
// 全局变量
|
||
let websocket = null;
|
||
let mediaRecorder = null;
|
||
let audioContext = null;
|
||
let analyser = null;
|
||
let audioChunks = [];
|
||
let isRecording = false;
|
||
let visualizerCanvas = document.getElementById('audioVisualizer');
|
||
let visualizerContext = visualizerCanvas.getContext('2d');
|
||
let audioQueue = [];
|
||
let isPlaying = false;
|
||
let opusDecoder = null; // Opus解码器
|
||
let visualizationRequest = null; // 动画帧请求ID
|
||
|
||
// 音频流缓冲相关
|
||
let audioBuffers = []; // 用于存储接收到的所有音频数据
|
||
let totalAudioSize = 0; // 跟踪累积的音频大小
|
||
|
||
let audioBufferQueue = []; // 存储接收到的音频包
|
||
let isAudioBuffering = false; // 是否正在缓冲音频
|
||
let isAudioPlaying = false; // 是否正在播放音频
|
||
const BUFFER_THRESHOLD = 3; // 缓冲包数量阈值,至少累积3个包再开始播放
|
||
const MIN_AUDIO_DURATION = 0.1; // 最小音频长度(秒),小于这个长度的音频会被合并
|
||
let streamingContext = null; // 音频流上下文
|
||
const SAMPLE_RATE = 16000; // 采样率
|
||
const CHANNELS = 1; // 声道数
|
||
const FRAME_SIZE = 960; // 帧大小
|
||
|
||
// DOM元素
|
||
const connectButton = document.getElementById('connectButton');
|
||
const serverUrlInput = document.getElementById('serverUrl');
|
||
const connectionStatus = document.getElementById('connectionStatus');
|
||
const messageInput = document.getElementById('messageInput');
|
||
const sendTextButton = document.getElementById('sendTextButton');
|
||
const recordButton = document.getElementById('recordButton');
|
||
const stopButton = document.getElementById('stopButton');
|
||
const conversationDiv = document.getElementById('conversation');
|
||
const logContainer = document.getElementById('logContainer');
|
||
|
||
// 日志函数
|
||
function log(message, type = 'info') {
|
||
// 将消息按换行符分割成多行
|
||
const lines = message.split('\n');
|
||
const now = new Date();
|
||
// const timestamp = `[${now.toLocaleTimeString()}] `;
|
||
const timestamp = `[${now.toLocaleTimeString()}.${now.getMilliseconds().toString().padStart(3, '0')}] `;
|
||
// 为每一行创建日志条目
|
||
lines.forEach((line, index) => {
|
||
const logEntry = document.createElement('div');
|
||
logEntry.className = `log-entry log-${type}`;
|
||
// 如果是第一条日志,显示时间戳
|
||
const prefix = index === 0 ? timestamp : ' '.repeat(timestamp.length);
|
||
logEntry.textContent = `${prefix}${line}`;
|
||
// logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
|
||
// logEntry.style 保留起始的空格
|
||
logEntry.style.whiteSpace = 'pre';
|
||
if (type === 'error') {
|
||
logEntry.style.color = 'red';
|
||
} else if (type === 'debug') {
|
||
logEntry.style.color = 'gray';
|
||
return;
|
||
} else if (type === 'warning') {
|
||
logEntry.style.color = 'orange';
|
||
} else if (type === 'success') {
|
||
logEntry.style.color = 'green';
|
||
} else {
|
||
logEntry.style.color = 'black';
|
||
}
|
||
logContainer.appendChild(logEntry);
|
||
});
|
||
|
||
logContainer.scrollTop = logContainer.scrollHeight;
|
||
}
|
||
|
||
// 初始化可视化器
|
||
function initVisualizer() {
|
||
visualizerCanvas.width = visualizerCanvas.clientWidth;
|
||
visualizerCanvas.height = visualizerCanvas.clientHeight;
|
||
visualizerContext.fillStyle = '#fafafa';
|
||
visualizerContext.fillRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
|
||
}
|
||
|
||
// 绘制音频可视化效果
|
||
function drawVisualizer(dataArray) {
|
||
visualizationRequest = requestAnimationFrame(() => drawVisualizer(dataArray));
|
||
|
||
if (!isRecording) return;
|
||
|
||
analyser.getByteFrequencyData(dataArray);
|
||
|
||
visualizerContext.fillStyle = '#fafafa';
|
||
visualizerContext.fillRect(0, 0, visualizerCanvas.width, visualizerCanvas.height);
|
||
|
||
const barWidth = (visualizerCanvas.width / dataArray.length) * 2.5;
|
||
let barHeight;
|
||
let x = 0;
|
||
|
||
for (let i = 0; i < dataArray.length; i++) {
|
||
barHeight = dataArray[i] / 2;
|
||
|
||
visualizerContext.fillStyle = `rgb(${barHeight + 100}, 50, 50)`;
|
||
visualizerContext.fillRect(x, visualizerCanvas.height - barHeight, barWidth, barHeight);
|
||
|
||
x += barWidth + 1;
|
||
}
|
||
}
|
||
|
||
// 添加消息到会话记录
|
||
function addMessage(text, isUser = false) {
|
||
const messageDiv = document.createElement('div');
|
||
messageDiv.className = `message ${isUser ? 'user' : 'server'}`;
|
||
messageDiv.textContent = text;
|
||
conversationDiv.appendChild(messageDiv);
|
||
conversationDiv.scrollTop = conversationDiv.scrollHeight;
|
||
}
|
||
|
||
|
||
// 开始音频缓冲过程
|
||
function startAudioBuffering() {
|
||
if (isAudioBuffering || isAudioPlaying) return;
|
||
|
||
isAudioBuffering = true;
|
||
log("开始音频缓冲...", 'info');
|
||
|
||
// 先尝试初始化解码器,以便在播放时已准备好
|
||
initOpusDecoder().catch(error => {
|
||
log(`预初始化Opus解码器失败: ${error.message}`, 'warning');
|
||
// 继续缓冲,我们会在播放时再次尝试初始化
|
||
});
|
||
|
||
// 设置超时,如果在一定时间内没有收集到足够的音频包,就开始播放
|
||
setTimeout(() => {
|
||
if (isAudioBuffering && audioBufferQueue.length > 0) {
|
||
log(`缓冲超时,当前缓冲包数: ${audioBufferQueue.length},开始播放`, 'info');
|
||
playBufferedAudio();
|
||
}
|
||
}, 300); // 300ms超时
|
||
|
||
// 监控缓冲进度
|
||
const bufferCheckInterval = setInterval(() => {
|
||
if (!isAudioBuffering) {
|
||
clearInterval(bufferCheckInterval);
|
||
return;
|
||
}
|
||
|
||
// 当累积了足够的音频包,开始播放
|
||
if (audioBufferQueue.length >= BUFFER_THRESHOLD) {
|
||
clearInterval(bufferCheckInterval);
|
||
log(`已缓冲 ${audioBufferQueue.length} 个音频包,开始播放`, 'info');
|
||
playBufferedAudio();
|
||
}
|
||
}, 50);
|
||
}
|
||
|
||
// 播放已缓冲的音频
|
||
function playBufferedAudio() {
|
||
if (isAudioPlaying || audioBufferQueue.length === 0) return;
|
||
|
||
isAudioPlaying = true;
|
||
isAudioBuffering = false;
|
||
|
||
// 确保Opus解码器已初始化
|
||
const initDecoderAndPlay = async () => {
|
||
try {
|
||
// 确保音频上下文存在
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||
sampleRate: SAMPLE_RATE
|
||
});
|
||
log('创建音频上下文,采样率: ' + SAMPLE_RATE + 'Hz', 'debug');
|
||
}
|
||
|
||
// 确保解码器已初始化
|
||
if (!opusDecoder) {
|
||
log('初始化Opus解码器...', 'info');
|
||
try {
|
||
opusDecoder = await initOpusDecoder();
|
||
if (!opusDecoder) {
|
||
throw new Error('解码器初始化失败');
|
||
}
|
||
log('Opus解码器初始化成功', 'success');
|
||
} catch (error) {
|
||
log('Opus解码器初始化失败: ' + error.message, 'error');
|
||
isAudioPlaying = false;
|
||
return;
|
||
}
|
||
}
|
||
|
||
// 创建流式播放上下文
|
||
if (!streamingContext) {
|
||
streamingContext = {
|
||
queue: [], // 已解码的PCM队列
|
||
playing: false, // 是否正在播放
|
||
endOfStream: false, // 是否收到结束信号
|
||
source: null, // 当前音频源
|
||
totalSamples: 0, // 累积的总样本数
|
||
lastPlayTime: 0, // 上次播放的时间戳
|
||
|
||
// 将Opus数据解码为PCM
|
||
decodeOpusFrames: async function (opusFrames) {
|
||
if (!opusDecoder) {
|
||
log('Opus解码器未初始化,无法解码', 'error');
|
||
return;
|
||
}
|
||
|
||
let decodedSamples = [];
|
||
|
||
for (const frame of opusFrames) {
|
||
try {
|
||
// 使用Opus解码器解码
|
||
const frameData = opusDecoder.decode(frame);
|
||
if (frameData && frameData.length > 0) {
|
||
// 转换为Float32
|
||
const floatData = convertInt16ToFloat32(frameData);
|
||
decodedSamples.push(...floatData);
|
||
}
|
||
} catch (error) {
|
||
log("Opus解码失败: " + error.message, 'error');
|
||
}
|
||
}
|
||
|
||
if (decodedSamples.length > 0) {
|
||
// 添加到解码队列
|
||
this.queue.push(...decodedSamples);
|
||
this.totalSamples += decodedSamples.length;
|
||
|
||
// 如果累积了至少0.2秒的音频,开始播放
|
||
const minSamples = SAMPLE_RATE * MIN_AUDIO_DURATION;
|
||
if (!this.playing && this.queue.length >= minSamples) {
|
||
this.startPlaying();
|
||
}
|
||
} else {
|
||
log('没有成功解码的样本', 'warning');
|
||
}
|
||
},
|
||
|
||
// 开始播放音频
|
||
startPlaying: function () {
|
||
if (this.playing || this.queue.length === 0) return;
|
||
|
||
this.playing = true;
|
||
|
||
// 创建新的音频缓冲区
|
||
const minPlaySamples = Math.min(this.queue.length, SAMPLE_RATE); // 最多播放1秒
|
||
const currentSamples = this.queue.splice(0, minPlaySamples);
|
||
|
||
const audioBuffer = audioContext.createBuffer(CHANNELS, currentSamples.length, SAMPLE_RATE);
|
||
audioBuffer.copyToChannel(new Float32Array(currentSamples), 0);
|
||
|
||
// 创建音频源
|
||
this.source = audioContext.createBufferSource();
|
||
this.source.buffer = audioBuffer;
|
||
|
||
// 创建增益节点用于平滑过渡
|
||
const gainNode = audioContext.createGain();
|
||
|
||
// 应用淡入淡出效果避免爆音
|
||
const fadeDuration = 0.02; // 20毫秒
|
||
gainNode.gain.setValueAtTime(0, audioContext.currentTime);
|
||
gainNode.gain.linearRampToValueAtTime(1, audioContext.currentTime + fadeDuration);
|
||
|
||
const duration = audioBuffer.duration;
|
||
if (duration > fadeDuration * 2) {
|
||
gainNode.gain.setValueAtTime(1, audioContext.currentTime + duration - fadeDuration);
|
||
gainNode.gain.linearRampToValueAtTime(0, audioContext.currentTime + duration);
|
||
}
|
||
|
||
// 连接节点并开始播放
|
||
this.source.connect(gainNode);
|
||
gainNode.connect(audioContext.destination);
|
||
|
||
this.lastPlayTime = audioContext.currentTime;
|
||
log(`开始播放 ${currentSamples.length} 个样本,约 ${(currentSamples.length / SAMPLE_RATE).toFixed(2)} 秒`, 'info');
|
||
|
||
// 播放结束后的处理
|
||
this.source.onended = () => {
|
||
this.source = null;
|
||
this.playing = false;
|
||
|
||
// 如果队列中还有数据或者缓冲区有新数据,继续播放
|
||
if (this.queue.length > 0) {
|
||
setTimeout(() => this.startPlaying(), 10);
|
||
} else if (audioBufferQueue.length > 0) {
|
||
// 缓冲区有新数据,进行解码
|
||
const frames = [...audioBufferQueue];
|
||
audioBufferQueue = [];
|
||
this.decodeOpusFrames(frames);
|
||
} else if (this.endOfStream) {
|
||
// 流已结束且没有更多数据
|
||
log("音频播放完成", 'info');
|
||
isAudioPlaying = false;
|
||
streamingContext = null;
|
||
} else {
|
||
// 等待更多数据
|
||
setTimeout(() => {
|
||
// 如果仍然没有新数据,但有更多的包到达
|
||
if (this.queue.length === 0 && audioBufferQueue.length > 0) {
|
||
const frames = [...audioBufferQueue];
|
||
audioBufferQueue = [];
|
||
this.decodeOpusFrames(frames);
|
||
} else if (this.queue.length === 0 && audioBufferQueue.length === 0) {
|
||
// 真的没有更多数据了
|
||
log("音频播放完成 (超时)", 'info');
|
||
isAudioPlaying = false;
|
||
streamingContext = null;
|
||
}
|
||
}, 500); // 500ms超时
|
||
}
|
||
};
|
||
|
||
this.source.start();
|
||
}
|
||
};
|
||
}
|
||
|
||
// 开始处理缓冲的数据
|
||
const frames = [...audioBufferQueue];
|
||
audioBufferQueue = []; // 清空缓冲队列
|
||
|
||
// 解码并播放
|
||
await streamingContext.decodeOpusFrames(frames);
|
||
|
||
} catch (error) {
|
||
log(`播放已缓冲的音频出错: ${error.message}`, 'error');
|
||
isAudioPlaying = false;
|
||
streamingContext = null;
|
||
}
|
||
};
|
||
|
||
// 执行初始化和播放
|
||
initDecoderAndPlay();
|
||
}
|
||
|
||
// 将Int16音频数据转换为Float32音频数据
|
||
function convertInt16ToFloat32(int16Data) {
|
||
const float32Data = new Float32Array(int16Data.length);
|
||
for (let i = 0; i < int16Data.length; i++) {
|
||
// 将[-32768,32767]范围转换为[-1,1]
|
||
float32Data[i] = int16Data[i] / (int16Data[i] < 0 ? 0x8000 : 0x7FFF);
|
||
}
|
||
return float32Data;
|
||
}
|
||
|
||
// 初始化Opus解码器 - 确保完全初始化完成后才返回
|
||
async function initOpusDecoder() {
|
||
if (opusDecoder) return opusDecoder; // 已经初始化
|
||
|
||
try {
|
||
// 检查ModuleInstance是否存在
|
||
if (typeof window.ModuleInstance === 'undefined') {
|
||
if (typeof Module !== 'undefined') {
|
||
// 使用全局Module作为ModuleInstance
|
||
window.ModuleInstance = Module;
|
||
log('使用全局Module作为ModuleInstance', 'info');
|
||
} else {
|
||
throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在');
|
||
}
|
||
}
|
||
|
||
const mod = window.ModuleInstance;
|
||
|
||
// 创建解码器对象
|
||
opusDecoder = {
|
||
channels: CHANNELS,
|
||
rate: SAMPLE_RATE,
|
||
frameSize: FRAME_SIZE,
|
||
module: mod,
|
||
decoderPtr: null, // 初始为null
|
||
|
||
// 初始化解码器
|
||
init: function () {
|
||
if (this.decoderPtr) return true; // 已经初始化
|
||
|
||
// 获取解码器大小
|
||
const decoderSize = mod._opus_decoder_get_size(this.channels);
|
||
log(`Opus解码器大小: ${decoderSize}字节`, 'debug');
|
||
|
||
// 分配内存
|
||
this.decoderPtr = mod._malloc(decoderSize);
|
||
if (!this.decoderPtr) {
|
||
throw new Error("无法分配解码器内存");
|
||
}
|
||
|
||
// 初始化解码器
|
||
const err = mod._opus_decoder_init(
|
||
this.decoderPtr,
|
||
this.rate,
|
||
this.channels
|
||
);
|
||
|
||
if (err < 0) {
|
||
this.destroy(); // 清理资源
|
||
throw new Error(`Opus解码器初始化失败: ${err}`);
|
||
}
|
||
|
||
log("Opus解码器初始化成功", 'success');
|
||
return true;
|
||
},
|
||
|
||
// 解码方法
|
||
decode: function (opusData) {
|
||
if (!this.decoderPtr) {
|
||
if (!this.init()) {
|
||
throw new Error("解码器未初始化且无法初始化");
|
||
}
|
||
}
|
||
|
||
try {
|
||
const mod = this.module;
|
||
|
||
// 为Opus数据分配内存
|
||
const opusPtr = mod._malloc(opusData.length);
|
||
mod.HEAPU8.set(opusData, opusPtr);
|
||
|
||
// 为PCM输出分配内存
|
||
const pcmPtr = mod._malloc(this.frameSize * 2); // Int16 = 2字节
|
||
|
||
// 解码
|
||
const decodedSamples = mod._opus_decode(
|
||
this.decoderPtr,
|
||
opusPtr,
|
||
opusData.length,
|
||
pcmPtr,
|
||
this.frameSize,
|
||
0 // 不使用FEC
|
||
);
|
||
|
||
if (decodedSamples < 0) {
|
||
mod._free(opusPtr);
|
||
mod._free(pcmPtr);
|
||
throw new Error(`Opus解码失败: ${decodedSamples}`);
|
||
}
|
||
|
||
// 复制解码后的数据
|
||
const decodedData = new Int16Array(decodedSamples);
|
||
for (let i = 0; i < decodedSamples; i++) {
|
||
decodedData[i] = mod.HEAP16[(pcmPtr >> 1) + i];
|
||
}
|
||
|
||
// 释放内存
|
||
mod._free(opusPtr);
|
||
mod._free(pcmPtr);
|
||
|
||
return decodedData;
|
||
} catch (error) {
|
||
log(`Opus解码错误: ${error.message}`, 'error');
|
||
return new Int16Array(0);
|
||
}
|
||
},
|
||
|
||
// 销毁方法
|
||
destroy: function () {
|
||
if (this.decoderPtr) {
|
||
this.module._free(this.decoderPtr);
|
||
this.decoderPtr = null;
|
||
}
|
||
}
|
||
};
|
||
|
||
// 初始化解码器
|
||
if (!opusDecoder.init()) {
|
||
throw new Error("Opus解码器初始化失败");
|
||
}
|
||
|
||
return opusDecoder;
|
||
|
||
} catch (error) {
|
||
log(`Opus解码器初始化失败: ${error.message}`, 'error');
|
||
opusDecoder = null; // 重置为null,以便下次重试
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
// 初始化音频录制和处理
|
||
async function initAudio() {
|
||
try {
|
||
// 请求麦克风权限
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: {
|
||
echoCancellation: true,
|
||
noiseSuppression: true,
|
||
sampleRate: 16000, // 确保16kHz采样率
|
||
channelCount: 1 // 确保单声道
|
||
}
|
||
});
|
||
log('已获取麦克风访问权限', 'success');
|
||
|
||
// 创建音频上下文
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||
sampleRate: 16000, // 确保采样率与服务器期望的一致
|
||
latencyHint: 'interactive'
|
||
});
|
||
const source = audioContext.createMediaStreamSource(stream);
|
||
|
||
// 获取实际音频轨道设置
|
||
const audioTracks = stream.getAudioTracks();
|
||
if (audioTracks.length > 0) {
|
||
const track = audioTracks[0];
|
||
const settings = track.getSettings();
|
||
log(`实际麦克风设置 - 采样率: ${settings.sampleRate || '未知'}Hz, 声道数: ${settings.channelCount || '未知'}`, 'info');
|
||
}
|
||
|
||
// 创建分析器用于可视化
|
||
analyser = audioContext.createAnalyser();
|
||
analyser.fftSize = 2048;
|
||
source.connect(analyser);
|
||
|
||
// 尝试初始化MediaRecorder,按优先级尝试不同编码选项
|
||
try {
|
||
// 优先尝试使用Opus编码
|
||
mediaRecorder = new MediaRecorder(stream, {
|
||
mimeType: 'audio/webm;codecs=opus',
|
||
audioBitsPerSecond: 16000
|
||
});
|
||
log('已初始化MediaRecorder (使用Opus编码)', 'success');
|
||
log(`选择的编码格式: ${mediaRecorder.mimeType}`, 'info');
|
||
} catch (e1) {
|
||
try {
|
||
// 如果Opus不支持,尝试MP3
|
||
mediaRecorder = new MediaRecorder(stream, {
|
||
mimeType: 'audio/webm',
|
||
audioBitsPerSecond: 16000
|
||
});
|
||
log('已初始化MediaRecorder (使用WebM标准编码,Opus不支持)', 'warning');
|
||
log(`选择的编码格式: ${mediaRecorder.mimeType}`, 'info');
|
||
} catch (e2) {
|
||
try {
|
||
// 尝试其他备选格式
|
||
mediaRecorder = new MediaRecorder(stream, {
|
||
mimeType: 'audio/ogg;codecs=opus',
|
||
audioBitsPerSecond: 16000
|
||
});
|
||
log('已初始化MediaRecorder (使用OGG+Opus编码)', 'warning');
|
||
log(`选择的编码格式: ${mediaRecorder.mimeType}`, 'info');
|
||
} catch (e3) {
|
||
// 最后使用默认编码
|
||
mediaRecorder = new MediaRecorder(stream);
|
||
log(`已初始化MediaRecorder (使用默认编码: ${mediaRecorder.mimeType})`, 'warning');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 处理录制的数据
|
||
mediaRecorder.ondataavailable = (event) => {
|
||
if (event.data.size > 0) {
|
||
audioChunks.push(event.data);
|
||
}
|
||
};
|
||
|
||
// 录制结束后处理数据
|
||
mediaRecorder.onstop = async () => {
|
||
// 停止可视化
|
||
if (visualizationRequest) {
|
||
cancelAnimationFrame(visualizationRequest);
|
||
visualizationRequest = null;
|
||
}
|
||
|
||
log(`录音结束,已收集的音频块数量: ${audioChunks.length}`, 'info');
|
||
if (audioChunks.length === 0) {
|
||
log('警告:没有收集到任何音频数据,请检查麦克风是否工作正常', 'error');
|
||
return;
|
||
}
|
||
|
||
// 创建完整的录音blob
|
||
const blob = new Blob(audioChunks, { type: audioChunks[0].type });
|
||
log(`已创建音频Blob,MIME类型: ${audioChunks[0].type},大小: ${(blob.size / 1024).toFixed(2)} KB`, 'info');
|
||
|
||
// 保存原始块,以防清空后需要调试
|
||
const chunks = [...audioChunks];
|
||
audioChunks = [];
|
||
|
||
try {
|
||
// 将blob转换为ArrayBuffer
|
||
const arrayBuffer = await blob.arrayBuffer();
|
||
const uint8Array = new Uint8Array(arrayBuffer);
|
||
|
||
log(`已转换为Uint8Array,准备发送,大小: ${(arrayBuffer.byteLength / 1024).toFixed(2)} KB`, 'info');
|
||
|
||
// 检查WebSocket状态
|
||
if (!websocket) {
|
||
log('错误:WebSocket连接不存在', 'error');
|
||
return;
|
||
}
|
||
|
||
if (websocket.readyState !== WebSocket.OPEN) {
|
||
log(`错误:WebSocket连接未打开,当前状态: ${websocket.readyState}`, 'error');
|
||
return;
|
||
}
|
||
|
||
// 直接发送二进制音频数据 - 这是最简单有效的方式
|
||
try {
|
||
// 注意:开始和结束消息已在录音开始和结束时发送
|
||
// 这里只需要发送音频数据
|
||
await new Promise(resolve => setTimeout(resolve, 50));
|
||
|
||
// 处理WebM容器格式,提取纯Opus数据
|
||
// 服务器使用opuslib_next.Decoder,需要纯Opus帧
|
||
log('正在处理音频数据,提取纯Opus帧...', 'info');
|
||
const opusData = extractOpusFrames(uint8Array);
|
||
|
||
// 记录Opus数据大小
|
||
log(`已提取Opus数据,大小: ${(opusData.byteLength / 1024).toFixed(2)} KB`, 'info');
|
||
|
||
// 发送音频消息第二步:二进制音频数据
|
||
websocket.send(opusData);
|
||
log(`已发送Opus音频数据: ${(opusData.byteLength / 1024).toFixed(2)} KB`, 'success');
|
||
} catch (error) {
|
||
log(`音频数据发送失败: ${error.message}`, 'error');
|
||
|
||
// 尝试使用base64编码作为备选方案
|
||
try {
|
||
log('尝试使用base64编码方式发送...', 'info');
|
||
const base64Data = arrayBufferToBase64(arrayBuffer);
|
||
const audioDataMessage = {
|
||
type: 'audio',
|
||
action: 'data',
|
||
format: 'opus',
|
||
sample_rate: 16000,
|
||
channels: 1,
|
||
mime_type: chunks[0].type,
|
||
encoding: 'base64',
|
||
data: base64Data
|
||
};
|
||
websocket.send(JSON.stringify(audioDataMessage));
|
||
log(`已使用base64编码发送音频数据: ${(arrayBuffer.byteLength / 1024).toFixed(2)} KB`, 'warning');
|
||
} catch (base64Error) {
|
||
log(`所有数据发送方式均失败: ${base64Error.message}`, 'error');
|
||
}
|
||
}
|
||
} catch (error) {
|
||
log(`处理录音数据错误: ${error.message}`, 'error');
|
||
}
|
||
};
|
||
|
||
// 尝试初始化Opus解码器
|
||
try {
|
||
// 检查ModuleInstance是否存在(本地库导出的全局变量)
|
||
if (typeof window.ModuleInstance === 'undefined') {
|
||
throw new Error('Opus库未加载,ModuleInstance对象不存在');
|
||
}
|
||
|
||
// 简单测试ModuleInstance是否可用
|
||
if (typeof window.ModuleInstance._opus_decoder_get_size === 'function') {
|
||
const testSize = window.ModuleInstance._opus_decoder_get_size(1);
|
||
log(`Opus解码器测试成功,解码器大小: ${testSize} 字节`, 'success');
|
||
} else {
|
||
throw new Error('Opus解码函数未找到');
|
||
}
|
||
} catch (err) {
|
||
log(`Opus解码器初始化警告: ${err.message},将在需要时重试`, 'warning');
|
||
}
|
||
|
||
log('音频系统初始化完成', 'success');
|
||
return true;
|
||
} catch (error) {
|
||
log(`音频初始化错误: ${error.message}`, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 开始录音
|
||
function startRecording() {
|
||
if (isRecording) return;
|
||
|
||
try {
|
||
// 最小录音时长提示
|
||
log('请至少录制1-2秒钟的音频,确保采集到足够数据', 'info');
|
||
|
||
// 获取服务器类型 - 从URL判断
|
||
const serverUrl = serverUrlInput.value.trim();
|
||
let isXiaozhiNative = false;
|
||
|
||
// 检查是否是小智原生服务器 (根据URL特征判断)
|
||
if (serverUrl.includes('xiaozhi') || serverUrl.includes('localhost') || serverUrl.includes('127.0.0.1')) {
|
||
isXiaozhiNative = true;
|
||
log('检测到小智原生服务器,使用标准listen协议', 'info');
|
||
}
|
||
|
||
// 使用直接PCM录音和libopus编码的方式
|
||
startDirectRecording();
|
||
} catch (error) {
|
||
log(`录音启动错误: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// 停止录音
|
||
function stopRecording() {
|
||
if (!isRecording) return;
|
||
|
||
try {
|
||
// 使用直接PCM录音停止
|
||
stopDirectRecording();
|
||
} catch (error) {
|
||
log(`停止录音错误: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// 连接WebSocket服务器
|
||
async function connectToServer() {
|
||
const url = serverUrlInput.value.trim();
|
||
if (url === '') return;
|
||
|
||
try {
|
||
// 获取并验证配置
|
||
const config = getConfig();
|
||
if (!validateConfig(config)) {
|
||
return;
|
||
}
|
||
|
||
// 检查URL格式
|
||
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
|
||
log('URL格式错误,必须以ws://或wss://开头', 'error');
|
||
return;
|
||
}
|
||
|
||
// 先检查OTA状态
|
||
log('正在检查OTA状态...', 'info');
|
||
const otaUrl = document.getElementById('otaUrl').value.trim();
|
||
|
||
try {
|
||
const otaResponse = await fetch(otaUrl, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'Device-Id': config.deviceId,
|
||
'Client-Id': config.clientId
|
||
},
|
||
body: JSON.stringify({
|
||
"version": 0,
|
||
"uuid": "",
|
||
"application": {
|
||
"name": "xiaozhi-web-test",
|
||
"version": "1.0.0",
|
||
"compile_time": "2025-04-16 10:00:00",
|
||
"idf_version": "4.4.3",
|
||
"elf_sha256": "1234567890abcdef1234567890abcdef1234567890abcdef"
|
||
},
|
||
"ota": {
|
||
"label": "xiaozhi-web-test",
|
||
},
|
||
"board": {
|
||
"type": "xiaozhi-web-test",
|
||
"ssid": "xiaozhi-web-test",
|
||
"rssi": 0,
|
||
"channel": 0,
|
||
"ip": "192.168.1.1",
|
||
"mac": config.deviceMac
|
||
},
|
||
"flash_size": 0,
|
||
"minimum_free_heap_size": 0,
|
||
"mac_address": config.deviceMac,
|
||
"chip_model_name": "",
|
||
"chip_info": {
|
||
"model": 0,
|
||
"cores": 0,
|
||
"revision": 0,
|
||
"features": 0
|
||
},
|
||
"partition_table": [
|
||
{
|
||
"label": "",
|
||
"type": 0,
|
||
"subtype": 0,
|
||
"address": 0,
|
||
"size": 0
|
||
}
|
||
]
|
||
})
|
||
});
|
||
|
||
if (!otaResponse.ok) {
|
||
throw new Error(`OTA检查失败: ${otaResponse.status} ${otaResponse.statusText}`);
|
||
}
|
||
|
||
const otaResult = await otaResponse.json();
|
||
log(`OTA检查结果: ${JSON.stringify(otaResult)}`, 'info');
|
||
|
||
log('OTA检查通过,开始连接WebSocket...', 'success');
|
||
document.getElementById('otaStatus').textContent = 'ota已连接';
|
||
document.getElementById('otaStatus').style.color = 'green';
|
||
} catch (error) {
|
||
log(`OTA检查错误: ${error.message}`, 'error');
|
||
document.getElementById('otaStatus').textContent = 'ota未连接';
|
||
document.getElementById('otaStatus').style.color = 'red';
|
||
}
|
||
|
||
// 使用自定义WebSocket实现以添加认证头信息
|
||
let connUrl = new URL(url);
|
||
|
||
// 添加认证参数
|
||
connUrl.searchParams.append('device-id', config.deviceId);
|
||
connUrl.searchParams.append('client-id', config.clientId);
|
||
|
||
log(`正在连接: ${connUrl.toString()}`, 'info');
|
||
websocket = new WebSocket(connUrl.toString());
|
||
|
||
// 设置接收二进制数据的类型为ArrayBuffer
|
||
websocket.binaryType = 'arraybuffer';
|
||
|
||
websocket.onopen = async () => {
|
||
log(`已连接到服务器: ${url}`, 'success');
|
||
connectionStatus.textContent = 'ws已连接';
|
||
connectionStatus.style.color = 'green';
|
||
|
||
// 连接成功后发送hello消息
|
||
await sendHelloMessage();
|
||
|
||
connectButton.textContent = '断开';
|
||
connectButton.removeEventListener('click', connectToServer);
|
||
connectButton.addEventListener('click', disconnectFromServer);
|
||
// connectButton.onclick = disconnectFromServer;
|
||
messageInput.disabled = false;
|
||
sendTextButton.disabled = false;
|
||
|
||
const audioInitialized = await initAudio();
|
||
if (audioInitialized) {
|
||
recordButton.disabled = false;
|
||
}
|
||
};
|
||
|
||
websocket.onclose = () => {
|
||
log('已断开连接', 'info');
|
||
connectionStatus.textContent = 'ws已断开';
|
||
connectionStatus.style.color = 'red';
|
||
|
||
connectButton.textContent = '连接';
|
||
connectButton.removeEventListener('click', disconnectFromServer);
|
||
connectButton.addEventListener('click', connectToServer);
|
||
// connectButton.onclick = connectToServer;
|
||
messageInput.disabled = true;
|
||
sendTextButton.disabled = true;
|
||
recordButton.disabled = true;
|
||
stopButton.disabled = true;
|
||
};
|
||
|
||
websocket.onerror = (error) => {
|
||
log(`WebSocket错误: ${error.message || '未知错误'}`, 'error');
|
||
connectionStatus.textContent = 'ws未连接';
|
||
connectionStatus.style.color = 'red';
|
||
};
|
||
|
||
websocket.onmessage = function (event) {
|
||
try {
|
||
// 检查是否为文本消息
|
||
if (typeof event.data === 'string') {
|
||
const message = JSON.parse(event.data);
|
||
|
||
if (message.type === 'hello') {
|
||
log(`服务器回应:${JSON.stringify(message, null, 2)}`, 'success');
|
||
} else if (message.type === 'tts') {
|
||
// TTS状态消息
|
||
if (message.state === 'start') {
|
||
log('服务器开始发送语音', 'info');
|
||
} else if (message.state === 'sentence_start') {
|
||
log(`服务器发送语音段: ${message.text}`, 'info');
|
||
// 添加文本到会话记录
|
||
if (message.text) {
|
||
addMessage(message.text);
|
||
}
|
||
} else if (message.state === 'sentence_end') {
|
||
log(`语音段结束: ${message.text}`, 'info');
|
||
} else if (message.state === 'stop') {
|
||
log('服务器语音传输结束', 'info');
|
||
// 结束后更新UI状态
|
||
if (recordButton.disabled) {
|
||
recordButton.disabled = false;
|
||
recordButton.textContent = '开始录音';
|
||
recordButton.classList.remove('recording');
|
||
}
|
||
}
|
||
} else if (message.type === 'audio') {
|
||
// 音频控制消息
|
||
log(`收到音频控制消息: ${JSON.stringify(message)}`, 'info');
|
||
} else if (message.type === 'stt') {
|
||
// 语音识别结果
|
||
log(`识别结果: ${message.text}`, 'info');
|
||
// 添加识别结果到会话记录
|
||
addMessage(`[语音识别] ${message.text}`, true);
|
||
} else if (message.type === 'llm') {
|
||
// 大模型回复
|
||
log(`大模型回复: ${message.text}`, 'info');
|
||
// 添加大模型回复到会话记录
|
||
if (message.text && message.text !== '😊') {
|
||
addMessage(message.text);
|
||
}
|
||
} else {
|
||
// 未知消息类型
|
||
log(`未知消息类型: ${message.type}`, 'info');
|
||
addMessage(JSON.stringify(message, null, 2));
|
||
}
|
||
} else {
|
||
// 处理二进制数据 - 兼容多种二进制格式
|
||
handleBinaryMessage(event.data);
|
||
}
|
||
} catch (error) {
|
||
log(`WebSocket消息处理错误: ${error.message}`, 'error');
|
||
// 非JSON格式文本消息直接显示
|
||
if (typeof event.data === 'string') {
|
||
addMessage(event.data);
|
||
}
|
||
}
|
||
};
|
||
|
||
connectionStatus.textContent = 'ws未连接';
|
||
connectionStatus.style.color = 'orange';
|
||
} catch (error) {
|
||
log(`连接错误: ${error.message}`, 'error');
|
||
connectionStatus.textContent = 'ws未连接';
|
||
}
|
||
}
|
||
|
||
// 发送hello握手消息
|
||
async function sendHelloMessage() {
|
||
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
|
||
|
||
try {
|
||
const config = getConfig();
|
||
|
||
// 设置设备信息
|
||
const helloMessage = {
|
||
type: 'hello',
|
||
device_id: config.deviceId,
|
||
device_name: config.deviceName,
|
||
device_mac: config.deviceMac,
|
||
token: config.token
|
||
};
|
||
|
||
log('发送hello握手消息', 'info');
|
||
websocket.send(JSON.stringify(helloMessage));
|
||
|
||
// 等待服务器响应
|
||
return new Promise(resolve => {
|
||
// 5秒超时
|
||
const timeout = setTimeout(() => {
|
||
log('等待hello响应超时', 'error');
|
||
log('提示: 请尝试点击"测试认证"按钮进行连接排查', 'info');
|
||
resolve(false);
|
||
}, 5000);
|
||
|
||
// 临时监听一次消息,接收hello响应
|
||
const onMessageHandler = (event) => {
|
||
try {
|
||
const response = JSON.parse(event.data);
|
||
if (response.type === 'hello' && response.session_id) {
|
||
log(`服务器握手成功,会话ID: ${response.session_id}`, 'success');
|
||
clearTimeout(timeout);
|
||
websocket.removeEventListener('message', onMessageHandler);
|
||
resolve(true);
|
||
}
|
||
} catch (e) {
|
||
// 忽略非JSON消息
|
||
}
|
||
};
|
||
|
||
websocket.addEventListener('message', onMessageHandler);
|
||
});
|
||
} catch (error) {
|
||
log(`发送hello消息错误: ${error.message}`, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 断开WebSocket连接
|
||
function disconnectFromServer() {
|
||
if (!websocket) return;
|
||
|
||
websocket.close();
|
||
stopRecording();
|
||
}
|
||
|
||
// 发送文本消息
|
||
function sendTextMessage() {
|
||
const message = messageInput.value.trim();
|
||
if (message === '' || !websocket || websocket.readyState !== WebSocket.OPEN) return;
|
||
|
||
try {
|
||
// 直接发送listen消息,不需要重复发送hello
|
||
const listenMessage = {
|
||
type: 'listen',
|
||
mode: 'manual',
|
||
state: 'detect',
|
||
text: message
|
||
};
|
||
|
||
websocket.send(JSON.stringify(listenMessage));
|
||
addMessage(message, true);
|
||
log(`发送文本消息: ${message}`, 'info');
|
||
|
||
messageInput.value = '';
|
||
} catch (error) {
|
||
log(`发送消息错误: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// 初始化事件监听器
|
||
function initEventListeners() {
|
||
connectButton.addEventListener('click', connectToServer);
|
||
document.getElementById('authTestButton').addEventListener('click', testAuthentication);
|
||
|
||
// 设备配置面板折叠/展开
|
||
const toggleButton = document.getElementById('toggleConfig');
|
||
const configPanel = document.getElementById('configPanel');
|
||
const deviceMacInput = document.getElementById('deviceMac');
|
||
const clientIdInput = document.getElementById('clientId');
|
||
const displayMac = document.getElementById('displayMac');
|
||
const displayClient = document.getElementById('displayClient');
|
||
|
||
// 更新显示的值
|
||
function updateDisplayValues() {
|
||
displayMac.textContent = deviceMacInput.value;
|
||
displayClient.textContent = clientIdInput.value;
|
||
}
|
||
|
||
// 监听输入变化
|
||
deviceMacInput.addEventListener('input', updateDisplayValues);
|
||
clientIdInput.addEventListener('input', updateDisplayValues);
|
||
|
||
// 初始更新显示值
|
||
updateDisplayValues();
|
||
|
||
// 切换面板显示
|
||
toggleButton.addEventListener('click', () => {
|
||
const isExpanded = configPanel.classList.contains('expanded');
|
||
configPanel.classList.toggle('expanded');
|
||
toggleButton.textContent = isExpanded ? '编辑' : '收起';
|
||
});
|
||
|
||
// 标签页切换
|
||
const tabs = document.querySelectorAll('.tab');
|
||
tabs.forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
// 移除所有标签页的active类
|
||
tabs.forEach(t => t.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
|
||
// 添加当前标签页的active类
|
||
tab.classList.add('active');
|
||
document.getElementById(`${tab.dataset.tab}Tab`).classList.add('active');
|
||
});
|
||
});
|
||
|
||
sendTextButton.addEventListener('click', sendTextMessage);
|
||
messageInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter') sendTextMessage();
|
||
});
|
||
|
||
recordButton.addEventListener('click', () => {
|
||
if (isRecording) {
|
||
stopRecording();
|
||
} else {
|
||
startRecording();
|
||
}
|
||
});
|
||
|
||
window.addEventListener('resize', initVisualizer);
|
||
}
|
||
|
||
// 测试认证
|
||
async function testAuthentication() {
|
||
log('开始测试认证...', 'info');
|
||
|
||
const config = getConfig();
|
||
|
||
// 显示服务器配置
|
||
log('-------- 服务器认证配置检查 --------', 'info');
|
||
log('请确认config.yaml中的auth配置:', 'info');
|
||
log('1. server.auth.enabled 为 false 或服务器已正确配置认证', 'info');
|
||
log('2. 如果启用了认证,请确认使用了正确的token', 'info');
|
||
log(`3. 或者在allowed_devices中添加了测试设备MAC:${config.deviceMac}`, 'info');
|
||
|
||
const serverUrl = serverUrlInput.value.trim();
|
||
if (!serverUrl) {
|
||
log('请输入服务器地址', 'error');
|
||
return;
|
||
}
|
||
|
||
// 测试连接
|
||
log('尝试不同认证参数的连接:', 'info');
|
||
|
||
// 测试1: 无参数连接
|
||
try {
|
||
log('测试1: 尝试无参数连接...', 'info');
|
||
const ws1 = new WebSocket(serverUrl);
|
||
|
||
ws1.onopen = () => {
|
||
log('测试1成功: 无参数可连接,服务器可能没有启用认证', 'success');
|
||
ws1.close();
|
||
};
|
||
|
||
ws1.onerror = (error) => {
|
||
log('测试1失败: 无参数连接被拒绝,服务器可能启用了认证', 'error');
|
||
};
|
||
|
||
// 5秒后关闭测试连接
|
||
setTimeout(() => {
|
||
if (ws1.readyState === WebSocket.CONNECTING || ws1.readyState === WebSocket.OPEN) {
|
||
ws1.close();
|
||
}
|
||
}, 5000);
|
||
} catch (error) {
|
||
log(`测试1出错: ${error.message}`, 'error');
|
||
}
|
||
|
||
// 测试2: 带参数连接
|
||
setTimeout(async () => {
|
||
try {
|
||
log('测试2: 尝试带token参数连接...', 'info');
|
||
|
||
let url = new URL(serverUrl);
|
||
url.searchParams.append('token', config.token);
|
||
url.searchParams.append('device_id', config.deviceId);
|
||
url.searchParams.append('device_mac', config.deviceMac);
|
||
|
||
const ws2 = new WebSocket(url.toString());
|
||
|
||
ws2.onopen = () => {
|
||
log('测试2成功: 带token参数可连接', 'success');
|
||
|
||
// 尝试发送hello消息
|
||
const helloMsg = {
|
||
type: 'hello',
|
||
device_id: config.deviceId,
|
||
device_mac: config.deviceMac,
|
||
token: config.token
|
||
};
|
||
|
||
ws2.send(JSON.stringify(helloMsg));
|
||
log('已发送hello测试消息', 'info');
|
||
|
||
// 监听响应
|
||
ws2.onmessage = (event) => {
|
||
try {
|
||
const response = JSON.parse(event.data);
|
||
if (response.type === 'hello' && response.session_id) {
|
||
log(`测试完全成功! 收到hello响应,会话ID: ${response.session_id}`, 'success');
|
||
ws2.close();
|
||
}
|
||
} catch (e) {
|
||
log(`收到非JSON响应: ${event.data}`, 'info');
|
||
}
|
||
};
|
||
|
||
// 5秒后关闭
|
||
setTimeout(() => ws2.close(), 5000);
|
||
};
|
||
|
||
ws2.onerror = (error) => {
|
||
log('测试2失败: 带token参数连接被拒绝', 'error');
|
||
log('请检查token是否正确,或服务器是否接受URL参数认证', 'error');
|
||
};
|
||
} catch (error) {
|
||
log(`测试2出错: ${error.message}`, 'error');
|
||
}
|
||
}, 6000);
|
||
|
||
log('认证测试已启动,请查看测试结果...', 'info');
|
||
}
|
||
|
||
// 帮助函数:ArrayBuffer转Base64
|
||
function arrayBufferToBase64(buffer) {
|
||
let binary = '';
|
||
const bytes = new Uint8Array(buffer);
|
||
const len = bytes.byteLength;
|
||
for (let i = 0; i < len; i++) {
|
||
binary += String.fromCharCode(bytes[i]);
|
||
}
|
||
return window.btoa(binary);
|
||
}
|
||
|
||
// 使用libopus创建一个Opus编码器
|
||
let opusEncoder = null;
|
||
function initOpusEncoder() {
|
||
try {
|
||
if (opusEncoder) {
|
||
return true; // 已经初始化过
|
||
}
|
||
|
||
if (!window.ModuleInstance) {
|
||
log('无法创建Opus编码器:ModuleInstance不可用', 'error');
|
||
return false;
|
||
}
|
||
|
||
// 初始化一个Opus编码器
|
||
const mod = window.ModuleInstance;
|
||
const sampleRate = 16000; // 16kHz采样率
|
||
const channels = 1; // 单声道
|
||
const application = 2048; // OPUS_APPLICATION_VOIP = 2048
|
||
|
||
// 创建编码器
|
||
opusEncoder = {
|
||
channels: channels,
|
||
sampleRate: sampleRate,
|
||
frameSize: 960, // 60ms @ 16kHz = 60 * 16 = 960 samples
|
||
maxPacketSize: 4000, // 最大包大小
|
||
module: mod,
|
||
|
||
// 初始化编码器
|
||
init: function () {
|
||
try {
|
||
// 获取编码器大小
|
||
const encoderSize = mod._opus_encoder_get_size(this.channels);
|
||
log(`Opus编码器大小: ${encoderSize}字节`, 'info');
|
||
|
||
// 分配内存
|
||
this.encoderPtr = mod._malloc(encoderSize);
|
||
if (!this.encoderPtr) {
|
||
throw new Error("无法分配编码器内存");
|
||
}
|
||
|
||
// 初始化编码器
|
||
const err = mod._opus_encoder_init(
|
||
this.encoderPtr,
|
||
this.sampleRate,
|
||
this.channels,
|
||
application
|
||
);
|
||
|
||
if (err < 0) {
|
||
throw new Error(`Opus编码器初始化失败: ${err}`);
|
||
}
|
||
|
||
// 设置位率 (16kbps)
|
||
mod._opus_encoder_ctl(this.encoderPtr, 4002, 16000); // OPUS_SET_BITRATE
|
||
|
||
// 设置复杂度 (0-10, 越高质量越好但CPU使用越多)
|
||
mod._opus_encoder_ctl(this.encoderPtr, 4010, 5); // OPUS_SET_COMPLEXITY
|
||
|
||
// 设置使用DTX (不传输静音帧)
|
||
mod._opus_encoder_ctl(this.encoderPtr, 4016, 1); // OPUS_SET_DTX
|
||
|
||
log("Opus编码器初始化成功", 'success');
|
||
return true;
|
||
} catch (error) {
|
||
if (this.encoderPtr) {
|
||
mod._free(this.encoderPtr);
|
||
this.encoderPtr = null;
|
||
}
|
||
log(`Opus编码器初始化失败: ${error.message}`, 'error');
|
||
return false;
|
||
}
|
||
},
|
||
|
||
// 编码PCM数据为Opus
|
||
encode: function (pcmData) {
|
||
if (!this.encoderPtr) {
|
||
if (!this.init()) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
try {
|
||
const mod = this.module;
|
||
|
||
// 为PCM数据分配内存
|
||
const pcmPtr = mod._malloc(pcmData.length * 2); // 2字节/int16
|
||
|
||
// 将PCM数据复制到HEAP
|
||
for (let i = 0; i < pcmData.length; i++) {
|
||
mod.HEAP16[(pcmPtr >> 1) + i] = pcmData[i];
|
||
}
|
||
|
||
// 为输出分配内存
|
||
const outPtr = mod._malloc(this.maxPacketSize);
|
||
|
||
// 进行编码
|
||
const encodedLen = mod._opus_encode(
|
||
this.encoderPtr,
|
||
pcmPtr,
|
||
this.frameSize,
|
||
outPtr,
|
||
this.maxPacketSize
|
||
);
|
||
|
||
if (encodedLen < 0) {
|
||
throw new Error(`Opus编码失败: ${encodedLen}`);
|
||
}
|
||
|
||
// 复制编码后的数据
|
||
const opusData = new Uint8Array(encodedLen);
|
||
for (let i = 0; i < encodedLen; i++) {
|
||
opusData[i] = mod.HEAPU8[outPtr + i];
|
||
}
|
||
|
||
// 释放内存
|
||
mod._free(pcmPtr);
|
||
mod._free(outPtr);
|
||
|
||
return opusData;
|
||
} catch (error) {
|
||
log(`Opus编码出错: ${error.message}`, 'error');
|
||
return null;
|
||
}
|
||
},
|
||
|
||
// 销毁编码器
|
||
destroy: function () {
|
||
if (this.encoderPtr) {
|
||
this.module._free(this.encoderPtr);
|
||
this.encoderPtr = null;
|
||
}
|
||
}
|
||
};
|
||
|
||
const result = opusEncoder.init();
|
||
return result;
|
||
} catch (error) {
|
||
log(`创建Opus编码器失败: ${error.message}`, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 初始化应用
|
||
function initApp() {
|
||
initVisualizer();
|
||
initEventListeners();
|
||
|
||
// 检查libopus.js是否正确加载
|
||
checkOpusLoaded();
|
||
|
||
// 初始化Opus编码器
|
||
initOpusEncoder();
|
||
|
||
// 预加载Opus解码器
|
||
log('预加载Opus解码器...', 'info');
|
||
initOpusDecoder().then(() => {
|
||
log('Opus解码器预加载成功', 'success');
|
||
}).catch(error => {
|
||
log(`Opus解码器预加载失败: ${error.message},将在需要时重试`, 'warning');
|
||
});
|
||
}
|
||
|
||
// PCM录音处理器代码 - 会被注入到AudioWorklet中
|
||
const audioProcessorCode = `
|
||
class AudioRecorderProcessor extends AudioWorkletProcessor {
|
||
constructor() {
|
||
super();
|
||
this.buffers = [];
|
||
this.frameSize = 960; // 60ms @ 16kHz = 960 samples
|
||
this.buffer = new Int16Array(this.frameSize);
|
||
this.bufferIndex = 0;
|
||
this.isRecording = false;
|
||
|
||
// 监听来自主线程的消息
|
||
this.port.onmessage = (event) => {
|
||
if (event.data.command === 'start') {
|
||
this.isRecording = true;
|
||
this.port.postMessage({ type: 'status', status: 'started' });
|
||
} else if (event.data.command === 'stop') {
|
||
this.isRecording = false;
|
||
|
||
// 发送剩余的缓冲区
|
||
if (this.bufferIndex > 0) {
|
||
const finalBuffer = this.buffer.slice(0, this.bufferIndex);
|
||
this.port.postMessage({
|
||
type: 'buffer',
|
||
buffer: finalBuffer
|
||
});
|
||
this.bufferIndex = 0;
|
||
}
|
||
|
||
this.port.postMessage({ type: 'status', status: 'stopped' });
|
||
}
|
||
};
|
||
}
|
||
|
||
process(inputs, outputs, parameters) {
|
||
if (!this.isRecording) return true;
|
||
|
||
const input = inputs[0][0]; // 获取第一个输入通道
|
||
if (!input) return true;
|
||
|
||
// 将浮点采样转换为16位整数并存储
|
||
for (let i = 0; i < input.length; i++) {
|
||
if (this.bufferIndex >= this.frameSize) {
|
||
// 缓冲区已满,发送给主线程并重置
|
||
this.port.postMessage({
|
||
type: 'buffer',
|
||
buffer: this.buffer.slice(0)
|
||
});
|
||
this.bufferIndex = 0;
|
||
}
|
||
|
||
// 转换为16位整数 (-32768到32767)
|
||
this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
|
||
}
|
||
|
||
return true;
|
||
}
|
||
}
|
||
|
||
registerProcessor('audio-recorder-processor', AudioRecorderProcessor);
|
||
`;
|
||
|
||
// 创建音频处理器
|
||
async function createAudioProcessor() {
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||
sampleRate: 16000,
|
||
latencyHint: 'interactive'
|
||
});
|
||
}
|
||
|
||
try {
|
||
// 检查是否支持AudioWorklet
|
||
if (audioContext.audioWorklet) {
|
||
// 注册音频处理器
|
||
const blob = new Blob([audioProcessorCode], { type: 'application/javascript' });
|
||
const url = URL.createObjectURL(blob);
|
||
await audioContext.audioWorklet.addModule(url);
|
||
URL.revokeObjectURL(url);
|
||
|
||
// 创建音频处理节点
|
||
const audioProcessor = new AudioWorkletNode(audioContext, 'audio-recorder-processor');
|
||
|
||
// 设置音频处理消息处理
|
||
audioProcessor.port.onmessage = (event) => {
|
||
if (event.data.type === 'buffer') {
|
||
// 收到PCM缓冲区数据
|
||
processPCMBuffer(event.data.buffer);
|
||
}
|
||
};
|
||
|
||
log('使用AudioWorklet处理音频', 'success');
|
||
return { node: audioProcessor, type: 'worklet' };
|
||
} else {
|
||
// 使用旧版ScriptProcessorNode作为回退方案
|
||
log('AudioWorklet不可用,使用ScriptProcessorNode作为回退方案', 'warning');
|
||
|
||
const frameSize = 4096; // ScriptProcessorNode缓冲区大小
|
||
const scriptProcessor = audioContext.createScriptProcessor(frameSize, 1, 1);
|
||
|
||
// 将audioProcess事件设置为处理音频数据
|
||
scriptProcessor.onaudioprocess = (event) => {
|
||
if (!isRecording) return;
|
||
|
||
const input = event.inputBuffer.getChannelData(0);
|
||
const buffer = new Int16Array(input.length);
|
||
|
||
// 将浮点数据转换为16位整数
|
||
for (let i = 0; i < input.length; i++) {
|
||
buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
|
||
}
|
||
|
||
// 处理PCM数据
|
||
processPCMBuffer(buffer);
|
||
};
|
||
|
||
// 需要连接输出,否则不会触发处理
|
||
// 我们创建一个静音通道
|
||
const silent = audioContext.createGain();
|
||
silent.gain.value = 0;
|
||
scriptProcessor.connect(silent);
|
||
silent.connect(audioContext.destination);
|
||
|
||
return { node: scriptProcessor, type: 'processor' };
|
||
}
|
||
} catch (error) {
|
||
log(`创建音频处理器失败: ${error.message},尝试回退方案`, 'error');
|
||
|
||
// 最后回退方案:使用ScriptProcessorNode
|
||
try {
|
||
const frameSize = 4096; // ScriptProcessorNode缓冲区大小
|
||
const scriptProcessor = audioContext.createScriptProcessor(frameSize, 1, 1);
|
||
|
||
scriptProcessor.onaudioprocess = (event) => {
|
||
if (!isRecording) return;
|
||
|
||
const input = event.inputBuffer.getChannelData(0);
|
||
const buffer = new Int16Array(input.length);
|
||
|
||
for (let i = 0; i < input.length; i++) {
|
||
buffer[i] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
|
||
}
|
||
|
||
processPCMBuffer(buffer);
|
||
};
|
||
|
||
const silent = audioContext.createGain();
|
||
silent.gain.value = 0;
|
||
scriptProcessor.connect(silent);
|
||
silent.connect(audioContext.destination);
|
||
|
||
log('使用ScriptProcessorNode作为回退方案成功', 'warning');
|
||
return { node: scriptProcessor, type: 'processor' };
|
||
} catch (fallbackError) {
|
||
log(`回退方案也失败: ${fallbackError.message}`, 'error');
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 初始化直接从PCM数据录音的系统
|
||
let audioProcessor = null;
|
||
let audioProcessorType = null;
|
||
let audioSource = null;
|
||
|
||
// 处理PCM缓冲数据
|
||
let pcmDataBuffer = new Int16Array();
|
||
function processPCMBuffer(buffer) {
|
||
if (!isRecording) return;
|
||
|
||
// 将新的PCM数据追加到缓冲区
|
||
const newBuffer = new Int16Array(pcmDataBuffer.length + buffer.length);
|
||
newBuffer.set(pcmDataBuffer);
|
||
newBuffer.set(buffer, pcmDataBuffer.length);
|
||
pcmDataBuffer = newBuffer;
|
||
|
||
// 检查是否有足够的数据进行Opus编码(16000Hz, 60ms = 960个采样点)
|
||
const samplesPerFrame = 960; // 60ms @ 16kHz
|
||
|
||
while (pcmDataBuffer.length >= samplesPerFrame) {
|
||
// 从缓冲区取出一帧数据
|
||
const frameData = pcmDataBuffer.slice(0, samplesPerFrame);
|
||
pcmDataBuffer = pcmDataBuffer.slice(samplesPerFrame);
|
||
|
||
// 编码为Opus
|
||
encodeAndSendOpus(frameData);
|
||
}
|
||
}
|
||
|
||
// 编码并发送Opus数据
|
||
function encodeAndSendOpus(pcmData = null) {
|
||
if (!opusEncoder) {
|
||
log('Opus编码器未初始化', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
// 如果提供了PCM数据,则编码该数据
|
||
if (pcmData) {
|
||
// 使用已初始化的Opus编码器编码
|
||
const opusData = opusEncoder.encode(pcmData);
|
||
|
||
if (opusData && opusData.length > 0) {
|
||
// 存储音频帧
|
||
audioBuffers.push(opusData.buffer);
|
||
totalAudioSize += opusData.length;
|
||
|
||
// 如果WebSocket已连接,则发送数据
|
||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||
try {
|
||
// 服务端期望接收原始Opus数据,不需要任何额外包装
|
||
websocket.send(opusData.buffer);
|
||
log(`发送Opus帧,大小:${opusData.length}字节`, 'debug');
|
||
} catch (error) {
|
||
log(`WebSocket发送错误: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
} else {
|
||
log('Opus编码失败,无有效数据返回', 'error');
|
||
}
|
||
} else {
|
||
// 处理剩余的PCM数据
|
||
if (pcmDataBuffer.length > 0) {
|
||
// 如果剩余的采样点不足一帧,用静音填充
|
||
const samplesPerFrame = 960;
|
||
if (pcmDataBuffer.length < samplesPerFrame) {
|
||
const paddedBuffer = new Int16Array(samplesPerFrame);
|
||
paddedBuffer.set(pcmDataBuffer);
|
||
// 剩余部分为0(静音)
|
||
encodeAndSendOpus(paddedBuffer);
|
||
} else {
|
||
encodeAndSendOpus(pcmDataBuffer.slice(0, samplesPerFrame));
|
||
}
|
||
pcmDataBuffer = new Int16Array(0);
|
||
}
|
||
}
|
||
} catch (error) {
|
||
log(`Opus编码错误: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// 开始直接从PCM数据录音
|
||
async function startDirectRecording() {
|
||
if (isRecording) return;
|
||
|
||
try {
|
||
// 初始化Opus编码器
|
||
if (!initOpusEncoder()) {
|
||
log('无法启动录音: Opus编码器初始化失败', 'error');
|
||
return;
|
||
}
|
||
|
||
// 请求麦克风权限
|
||
const stream = await navigator.mediaDevices.getUserMedia({
|
||
audio: {
|
||
echoCancellation: true,
|
||
noiseSuppression: true,
|
||
sampleRate: 16000,
|
||
channelCount: 1
|
||
}
|
||
});
|
||
|
||
// 创建音频上下文和分析器
|
||
if (!audioContext) {
|
||
audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||
sampleRate: 16000,
|
||
latencyHint: 'interactive'
|
||
});
|
||
}
|
||
|
||
// 创建音频处理器
|
||
const processorResult = await createAudioProcessor();
|
||
if (!processorResult) {
|
||
log('无法创建音频处理器', 'error');
|
||
return;
|
||
}
|
||
|
||
audioProcessor = processorResult.node;
|
||
audioProcessorType = processorResult.type;
|
||
|
||
// 连接音频处理链
|
||
audioSource = audioContext.createMediaStreamSource(stream);
|
||
analyser = audioContext.createAnalyser();
|
||
analyser.fftSize = 2048;
|
||
|
||
audioSource.connect(analyser);
|
||
audioSource.connect(audioProcessor);
|
||
|
||
// 启动录音
|
||
pcmDataBuffer = new Int16Array();
|
||
audioBuffers = [];
|
||
totalAudioSize = 0;
|
||
isRecording = true;
|
||
|
||
// 启动音频处理器的录音 - 只有AudioWorklet才需要发送消息
|
||
if (audioProcessorType === 'worklet' && audioProcessor.port) {
|
||
audioProcessor.port.postMessage({ command: 'start' });
|
||
}
|
||
|
||
// 发送监听开始消息
|
||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||
// 使用与服务端期望的listen消息格式
|
||
const listenMessage = {
|
||
type: 'listen',
|
||
mode: 'manual', // 使用手动模式,由我们控制开始/停止
|
||
state: 'start' // 表示开始录音
|
||
};
|
||
|
||
log(`发送录音开始消息: ${JSON.stringify(listenMessage)}`, 'info');
|
||
websocket.send(JSON.stringify(listenMessage));
|
||
} else {
|
||
log('WebSocket未连接,无法发送开始消息', 'error');
|
||
return false;
|
||
}
|
||
|
||
// 开始音频可视化
|
||
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
||
drawVisualizer(dataArray);
|
||
|
||
// 在UI上显示录音计时器
|
||
let recordingSeconds = 0;
|
||
const recordingTimer = setInterval(() => {
|
||
recordingSeconds += 0.1;
|
||
recordButton.textContent = `停止录音 ${recordingSeconds.toFixed(1)}秒`;
|
||
}, 100);
|
||
|
||
// 保存计时器,以便在停止时清除
|
||
window.recordingTimer = recordingTimer;
|
||
|
||
recordButton.classList.add('recording');
|
||
recordButton.disabled = false;
|
||
|
||
log('开始PCM直接录音', 'success');
|
||
return true;
|
||
} catch (error) {
|
||
log(`直接录音启动错误: ${error.message}`, 'error');
|
||
isRecording = false;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 停止直接从PCM数据录音
|
||
function stopDirectRecording() {
|
||
if (!isRecording) return;
|
||
|
||
try {
|
||
// 停止录音
|
||
isRecording = false;
|
||
|
||
// 停止音频处理器的录音
|
||
if (audioProcessor) {
|
||
// 只有AudioWorklet才需要发送停止消息
|
||
if (audioProcessorType === 'worklet' && audioProcessor.port) {
|
||
audioProcessor.port.postMessage({ command: 'stop' });
|
||
}
|
||
|
||
audioProcessor.disconnect();
|
||
audioProcessor = null;
|
||
}
|
||
|
||
// 断开音频连接
|
||
if (audioSource) {
|
||
audioSource.disconnect();
|
||
audioSource = null;
|
||
}
|
||
|
||
// 停止可视化
|
||
if (visualizationRequest) {
|
||
cancelAnimationFrame(visualizationRequest);
|
||
visualizationRequest = null;
|
||
}
|
||
|
||
// 清除录音计时器
|
||
if (window.recordingTimer) {
|
||
clearInterval(window.recordingTimer);
|
||
window.recordingTimer = null;
|
||
}
|
||
|
||
// 编码并发送剩余的数据
|
||
encodeAndSendOpus();
|
||
|
||
// 发送一个空的消息作为结束标志(模拟接收到空音频数据的情况)
|
||
if (websocket && websocket.readyState === WebSocket.OPEN) {
|
||
// 使用空的Uint8Array发送最后一个空帧
|
||
const emptyOpusFrame = new Uint8Array(0);
|
||
websocket.send(emptyOpusFrame);
|
||
|
||
// 发送监听结束消息
|
||
const stopMessage = {
|
||
type: 'listen',
|
||
mode: 'manual',
|
||
state: 'stop'
|
||
};
|
||
|
||
websocket.send(JSON.stringify(stopMessage));
|
||
log('已发送录音停止信号', 'info');
|
||
}
|
||
|
||
// 重置UI
|
||
recordButton.textContent = '开始录音';
|
||
recordButton.classList.remove('recording');
|
||
recordButton.disabled = false;
|
||
|
||
log('停止PCM直接录音', 'success');
|
||
return true;
|
||
} catch (error) {
|
||
log(`直接录音停止错误: ${error.message}`, 'error');
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async function handleBinaryMessage(data) {
|
||
try {
|
||
let arrayBuffer;
|
||
|
||
// 根据数据类型进行处理
|
||
if (data instanceof ArrayBuffer) {
|
||
arrayBuffer = data;
|
||
log(`收到ArrayBuffer音频数据,大小: ${data.byteLength}字节`, 'debug');
|
||
} else if (data instanceof Blob) {
|
||
// 如果是Blob类型,转换为ArrayBuffer
|
||
arrayBuffer = await data.arrayBuffer();
|
||
log(`收到Blob音频数据,大小: ${arrayBuffer.byteLength}字节`, 'debug');
|
||
} else {
|
||
log(`收到未知类型的二进制数据: ${typeof data}`, 'warning');
|
||
return;
|
||
}
|
||
|
||
// 创建Uint8Array用于处理
|
||
const opusData = new Uint8Array(arrayBuffer);
|
||
|
||
if (opusData.length > 0) {
|
||
// 将数据添加到缓冲队列
|
||
audioBufferQueue.push(opusData);
|
||
|
||
// 如果收到的是第一个音频包,开始缓冲过程
|
||
if (audioBufferQueue.length === 1 && !isAudioBuffering && !isAudioPlaying) {
|
||
startAudioBuffering();
|
||
}
|
||
} else {
|
||
log('收到空音频数据帧,可能是结束标志', 'warning');
|
||
|
||
// 如果缓冲队列中有数据且没有在播放,立即开始播放
|
||
if (audioBufferQueue.length > 0 && !isAudioPlaying) {
|
||
playBufferedAudio();
|
||
}
|
||
|
||
// 如果正在播放,发送结束信号
|
||
if (isAudioPlaying && streamingContext) {
|
||
streamingContext.endOfStream = true;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
log(`处理二进制消息出错: ${error.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
// 获取配置值
|
||
function getConfig() {
|
||
const deviceMac = document.getElementById('deviceMac').value.trim();
|
||
return {
|
||
deviceId: deviceMac, // 使用MAC地址作为deviceId
|
||
deviceName: document.getElementById('deviceName').value.trim(),
|
||
deviceMac: deviceMac,
|
||
clientId: document.getElementById('clientId').value.trim(),
|
||
token: document.getElementById('token').value.trim()
|
||
};
|
||
}
|
||
|
||
// 验证配置
|
||
function validateConfig(config) {
|
||
if (!config.deviceMac) {
|
||
log('设备MAC地址不能为空', 'error');
|
||
return false;
|
||
}
|
||
if (!config.clientId) {
|
||
log('客户端ID不能为空', 'error');
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
initApp();
|
||
</script>
|
||
</body>
|
||
|
||
</html> |