You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

2104 lines
86 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<!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;
}
.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 {
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;
}
</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>WebSocket连接 <span id="connectionStatus" class="status">未连接</span></h2>
<div class="control-panel">
<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">
<h2>消息发送</h2>
<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.instancelibopus.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 logEntry = document.createElement('div');
logEntry.className = `log-entry log-${type}`;
logEntry.textContent = `[${new Date().toLocaleTimeString()}] ${message}`;
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(`已创建音频BlobMIME类型: ${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服务器
function connectToServer() {
const url = serverUrlInput.value.trim();
if (url === '') return;
try {
// 检查URL格式
if (!url.startsWith('ws://') && !url.startsWith('wss://')) {
log('URL格式错误必须以ws://或wss://开头', 'error');
return;
}
// 使用自定义WebSocket实现以添加认证头信息
// 注意浏览器原生WebSocket不支持自定义头需要通过服务器添加一个代理层
// 但我们可以通过URL参数模拟认证信息
let connUrl = new URL(url);
// 添加认证参数
connUrl.searchParams.append('device_id', 'web_test_device');
connUrl.searchParams.append('device_mac', '00:11:22:33:44:55');
log(`正在连接: ${connUrl.toString()}`, 'info');
websocket = new WebSocket(connUrl.toString());
// 设置接收二进制数据的类型为ArrayBuffer
websocket.binaryType = 'arraybuffer';
websocket.onopen = async () => {
log(`已连接到服务器: ${url}`, 'success');
connectionStatus.textContent = '已连接';
connectionStatus.style.color = 'green';
// 连接成功后发送hello消息
await sendHelloMessage();
connectButton.textContent = '断开';
connectButton.onclick = disconnectFromServer;
messageInput.disabled = false;
sendTextButton.disabled = false;
const audioInitialized = await initAudio();
if (audioInitialized) {
recordButton.disabled = false;
}
};
websocket.onclose = () => {
log('已断开连接', 'info');
connectionStatus.textContent = '已断开';
connectionStatus.style.color = 'red';
connectButton.textContent = '连接';
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 = '连接错误';
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(`服务器回应:${message.message}`, 'info');
} 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 = '正在连接...';
connectionStatus.style.color = 'orange';
} catch (error) {
log(`连接错误: ${error.message}`, 'error');
connectionStatus.textContent = '连接失败';
}
}
// 发送hello握手消息
async function sendHelloMessage() {
if (!websocket || websocket.readyState !== WebSocket.OPEN) return;
try {
// 设置设备信息
const helloMessage = {
type: 'hello',
device_id: 'web_test_device',
device_name: 'Web测试设备',
device_mac: '00:11:22:33:44:55',
token: 'your-token1' // 使用config.yaml中配置的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 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');
// 显示服务器配置
log('-------- 服务器认证配置检查 --------', 'info');
log('请确认config.yaml中的auth配置', 'info');
log('1. server.auth.enabled 为 false 或服务器已正确配置认证', 'info');
log('2. 如果启用了认证请确认使用了正确的token', 'info');
log('3. 或者在allowed_devices中添加了测试设备MAC00:11:22:33:44:55', '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', 'your-token1');
url.searchParams.append('device_id', 'web_test_device');
url.searchParams.append('device_mac', '00:11:22:33:44:55');
const ws2 = new WebSocket(url.toString());
ws2.onopen = () => {
log('测试2成功: 带token参数可连接', 'success');
// 尝试发送hello消息
const helloMsg = {
type: 'hello',
device_id: 'web_test_device',
device_mac: '00:11:22:33:44:55',
token: 'your-token1'
};
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');
}
}
initApp();
</script>
</body>
</html>