mirror of
https://github.com/xinnan-tech/xiaozhi-esp32-server.git
synced 2026-07-29 18:23:59 +08:00
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(tree:*)",
|
||||||
|
"Bash(find:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 185 B |
+654
-300
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,50 @@
|
|||||||
|
// 主应用入口
|
||||||
|
import { log } from './utils/logger.js';
|
||||||
|
import { checkOpusLoaded, initOpusEncoder } from './core/audio/opus-codec.js';
|
||||||
|
import { getUIController } from './ui/controller.js';
|
||||||
|
import { getAudioPlayer } from './core/audio/player.js';
|
||||||
|
import { initMcpTools } from './core/mcp/tools.js';
|
||||||
|
|
||||||
|
// 应用类
|
||||||
|
class App {
|
||||||
|
constructor() {
|
||||||
|
this.uiController = null;
|
||||||
|
this.audioPlayer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化应用
|
||||||
|
async init() {
|
||||||
|
log('正在初始化应用...', 'info');
|
||||||
|
|
||||||
|
// 初始化UI控制器
|
||||||
|
this.uiController = getUIController();
|
||||||
|
this.uiController.init();
|
||||||
|
|
||||||
|
// 检查Opus库
|
||||||
|
checkOpusLoaded();
|
||||||
|
|
||||||
|
// 初始化Opus编码器
|
||||||
|
initOpusEncoder();
|
||||||
|
|
||||||
|
// 初始化音频播放器
|
||||||
|
this.audioPlayer = getAudioPlayer();
|
||||||
|
await this.audioPlayer.start();
|
||||||
|
|
||||||
|
// 初始化MCP工具
|
||||||
|
initMcpTools();
|
||||||
|
|
||||||
|
log('应用初始化完成', 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建并启动应用
|
||||||
|
const app = new App();
|
||||||
|
|
||||||
|
// DOM加载完成后初始化
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', () => app.init());
|
||||||
|
} else {
|
||||||
|
app.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
// 配置管理模块
|
||||||
|
import { log } from '../utils/logger.js';
|
||||||
|
|
||||||
|
// 生成随机MAC地址
|
||||||
|
function generateRandomMac() {
|
||||||
|
const hexDigits = '0123456789ABCDEF';
|
||||||
|
let mac = '';
|
||||||
|
for (let i = 0; i < 6; i++) {
|
||||||
|
if (i > 0) mac += ':';
|
||||||
|
for (let j = 0; j < 2; j++) {
|
||||||
|
mac += hexDigits.charAt(Math.floor(Math.random() * 16));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mac;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
export function loadConfig() {
|
||||||
|
const deviceMacInput = document.getElementById('deviceMac');
|
||||||
|
const deviceNameInput = document.getElementById('deviceName');
|
||||||
|
const clientIdInput = document.getElementById('clientId');
|
||||||
|
const tokenInput = document.getElementById('token');
|
||||||
|
const otaUrlInput = document.getElementById('otaUrl');
|
||||||
|
|
||||||
|
// 从localStorage加载MAC地址,如果没有则生成新的
|
||||||
|
let savedMac = localStorage.getItem('deviceMac');
|
||||||
|
if (!savedMac) {
|
||||||
|
savedMac = generateRandomMac();
|
||||||
|
localStorage.setItem('deviceMac', savedMac);
|
||||||
|
}
|
||||||
|
deviceMacInput.value = savedMac;
|
||||||
|
|
||||||
|
// 从localStorage加载其他配置
|
||||||
|
const savedDeviceName = localStorage.getItem('deviceName');
|
||||||
|
if (savedDeviceName) {
|
||||||
|
deviceNameInput.value = savedDeviceName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedClientId = localStorage.getItem('clientId');
|
||||||
|
if (savedClientId) {
|
||||||
|
clientIdInput.value = savedClientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedToken = localStorage.getItem('token');
|
||||||
|
if (savedToken) {
|
||||||
|
tokenInput.value = savedToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedOtaUrl = localStorage.getItem('otaUrl');
|
||||||
|
if (savedOtaUrl) {
|
||||||
|
otaUrlInput.value = savedOtaUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存配置
|
||||||
|
export function saveConfig() {
|
||||||
|
const deviceMacInput = document.getElementById('deviceMac');
|
||||||
|
const deviceNameInput = document.getElementById('deviceName');
|
||||||
|
const clientIdInput = document.getElementById('clientId');
|
||||||
|
const tokenInput = document.getElementById('token');
|
||||||
|
|
||||||
|
localStorage.setItem('deviceMac', deviceMacInput.value);
|
||||||
|
localStorage.setItem('deviceName', deviceNameInput.value);
|
||||||
|
localStorage.setItem('clientId', clientIdInput.value);
|
||||||
|
localStorage.setItem('token', tokenInput.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置值
|
||||||
|
export 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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存连接URL
|
||||||
|
export function saveConnectionUrls() {
|
||||||
|
const otaUrl = document.getElementById('otaUrl').value.trim();
|
||||||
|
const wsUrl = document.getElementById('serverUrl').value.trim();
|
||||||
|
localStorage.setItem('otaUrl', otaUrl);
|
||||||
|
localStorage.setItem('wsUrl', wsUrl);
|
||||||
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { log } from './utils/logger.js';
|
import { log } from '../../utils/logger.js';
|
||||||
import { updateScriptStatus } from './document.js'
|
import { updateScriptStatus } from '../../ui/dom-helper.js'
|
||||||
|
|
||||||
|
|
||||||
// 检查Opus库是否已加载
|
// 检查Opus库是否已加载
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
// 音频播放模块
|
||||||
|
import { log } from '../../utils/logger.js';
|
||||||
|
import BlockingQueue from '../../utils/blocking-queue.js';
|
||||||
|
import { createStreamingContext } from './stream-context.js';
|
||||||
|
|
||||||
|
// 音频播放器类
|
||||||
|
export class AudioPlayer {
|
||||||
|
constructor() {
|
||||||
|
// 音频参数
|
||||||
|
this.SAMPLE_RATE = 16000;
|
||||||
|
this.CHANNELS = 1;
|
||||||
|
this.FRAME_SIZE = 960;
|
||||||
|
this.MIN_AUDIO_DURATION = 0.12;
|
||||||
|
|
||||||
|
// 状态
|
||||||
|
this.audioContext = null;
|
||||||
|
this.opusDecoder = null;
|
||||||
|
this.streamingContext = null;
|
||||||
|
this.queue = new BlockingQueue();
|
||||||
|
this.isPlaying = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取或创建AudioContext
|
||||||
|
getAudioContext() {
|
||||||
|
if (!this.audioContext) {
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)({
|
||||||
|
sampleRate: this.SAMPLE_RATE,
|
||||||
|
latencyHint: 'interactive'
|
||||||
|
});
|
||||||
|
log('创建音频上下文,采样率: ' + this.SAMPLE_RATE + 'Hz', 'debug');
|
||||||
|
}
|
||||||
|
return this.audioContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化Opus解码器
|
||||||
|
async initOpusDecoder() {
|
||||||
|
if (this.opusDecoder) return this.opusDecoder;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof window.ModuleInstance === 'undefined') {
|
||||||
|
if (typeof Module !== 'undefined') {
|
||||||
|
window.ModuleInstance = Module;
|
||||||
|
log('使用全局Module作为ModuleInstance', 'info');
|
||||||
|
} else {
|
||||||
|
throw new Error('Opus库未加载,ModuleInstance和Module对象都不存在');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mod = window.ModuleInstance;
|
||||||
|
|
||||||
|
this.opusDecoder = {
|
||||||
|
channels: this.CHANNELS,
|
||||||
|
rate: this.SAMPLE_RATE,
|
||||||
|
frameSize: this.FRAME_SIZE,
|
||||||
|
module: mod,
|
||||||
|
decoderPtr: 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;
|
||||||
|
|
||||||
|
const opusPtr = mod._malloc(opusData.length);
|
||||||
|
mod.HEAPU8.set(opusData, opusPtr);
|
||||||
|
|
||||||
|
const pcmPtr = mod._malloc(this.frameSize * 2);
|
||||||
|
|
||||||
|
const decodedSamples = mod._opus_decode(
|
||||||
|
this.decoderPtr,
|
||||||
|
opusPtr,
|
||||||
|
opusData.length,
|
||||||
|
pcmPtr,
|
||||||
|
this.frameSize,
|
||||||
|
0
|
||||||
|
);
|
||||||
|
|
||||||
|
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 (!this.opusDecoder.init()) {
|
||||||
|
throw new Error("Opus解码器初始化失败");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.opusDecoder;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log(`Opus解码器初始化失败: ${error.message}`, 'error');
|
||||||
|
this.opusDecoder = null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动音频缓冲
|
||||||
|
async startAudioBuffering() {
|
||||||
|
log("开始音频缓冲...", 'info');
|
||||||
|
|
||||||
|
this.initOpusDecoder().catch(error => {
|
||||||
|
log(`预初始化Opus解码器失败: ${error.message}`, 'warning');
|
||||||
|
});
|
||||||
|
|
||||||
|
const timeout = 400;
|
||||||
|
while (true) {
|
||||||
|
const packets = await this.queue.dequeue(
|
||||||
|
6,
|
||||||
|
timeout,
|
||||||
|
(count) => {
|
||||||
|
log(`缓冲超时,当前缓冲包数: ${count},开始播放`, 'info');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (packets.length) {
|
||||||
|
log(`已缓冲 ${packets.length} 个音频包,开始播放`, 'info');
|
||||||
|
this.streamingContext.pushAudioBuffer(packets);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const data = await this.queue.dequeue(99, 30);
|
||||||
|
if (data.length) {
|
||||||
|
this.streamingContext.pushAudioBuffer(data);
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 播放已缓冲的音频
|
||||||
|
async playBufferedAudio() {
|
||||||
|
try {
|
||||||
|
this.audioContext = this.getAudioContext();
|
||||||
|
|
||||||
|
if (!this.opusDecoder) {
|
||||||
|
log('初始化Opus解码器...', 'info');
|
||||||
|
try {
|
||||||
|
this.opusDecoder = await this.initOpusDecoder();
|
||||||
|
if (!this.opusDecoder) {
|
||||||
|
throw new Error('解码器初始化失败');
|
||||||
|
}
|
||||||
|
log('Opus解码器初始化成功', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
log('Opus解码器初始化失败: ' + error.message, 'error');
|
||||||
|
this.isPlaying = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.streamingContext) {
|
||||||
|
this.streamingContext = createStreamingContext(
|
||||||
|
this.opusDecoder,
|
||||||
|
this.audioContext,
|
||||||
|
this.SAMPLE_RATE,
|
||||||
|
this.CHANNELS,
|
||||||
|
this.MIN_AUDIO_DURATION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.streamingContext.decodeOpusFrames();
|
||||||
|
this.streamingContext.startPlaying();
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log(`播放已缓冲的音频出错: ${error.message}`, 'error');
|
||||||
|
this.isPlaying = false;
|
||||||
|
this.streamingContext = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加音频数据到队列
|
||||||
|
enqueueAudioData(opusData) {
|
||||||
|
if (opusData.length > 0) {
|
||||||
|
this.queue.enqueue(opusData);
|
||||||
|
} else {
|
||||||
|
log('收到空音频数据帧,可能是结束标志', 'warning');
|
||||||
|
if (this.isPlaying && this.streamingContext) {
|
||||||
|
this.streamingContext.endOfStream = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 预加载解码器
|
||||||
|
async preload() {
|
||||||
|
log('预加载Opus解码器...', 'info');
|
||||||
|
try {
|
||||||
|
await this.initOpusDecoder();
|
||||||
|
log('Opus解码器预加载成功', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
log(`Opus解码器预加载失败: ${error.message},将在需要时重试`, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动播放系统
|
||||||
|
async start() {
|
||||||
|
await this.preload();
|
||||||
|
this.playBufferedAudio();
|
||||||
|
this.startAudioBuffering();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例
|
||||||
|
let audioPlayerInstance = null;
|
||||||
|
|
||||||
|
export function getAudioPlayer() {
|
||||||
|
if (!audioPlayerInstance) {
|
||||||
|
audioPlayerInstance = new AudioPlayer();
|
||||||
|
}
|
||||||
|
return audioPlayerInstance;
|
||||||
|
}
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
// 音频录制模块
|
||||||
|
import { log } from '../../utils/logger.js';
|
||||||
|
import { initOpusEncoder } from './opus-codec.js';
|
||||||
|
import { getAudioPlayer } from './player.js';
|
||||||
|
|
||||||
|
// 音频录制器类
|
||||||
|
export class AudioRecorder {
|
||||||
|
constructor() {
|
||||||
|
this.isRecording = false;
|
||||||
|
this.audioContext = null;
|
||||||
|
this.analyser = null;
|
||||||
|
this.audioProcessor = null;
|
||||||
|
this.audioProcessorType = null;
|
||||||
|
this.audioSource = null;
|
||||||
|
this.opusEncoder = null;
|
||||||
|
this.pcmDataBuffer = new Int16Array();
|
||||||
|
this.audioBuffers = [];
|
||||||
|
this.totalAudioSize = 0;
|
||||||
|
this.visualizationRequest = null;
|
||||||
|
this.recordingTimer = null;
|
||||||
|
this.websocket = null;
|
||||||
|
|
||||||
|
// 回调函数
|
||||||
|
this.onRecordingStart = null;
|
||||||
|
this.onRecordingStop = null;
|
||||||
|
this.onVisualizerUpdate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置WebSocket实例
|
||||||
|
setWebSocket(ws) {
|
||||||
|
this.websocket = ws;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取AudioContext实例
|
||||||
|
getAudioContext() {
|
||||||
|
const audioPlayer = getAudioPlayer();
|
||||||
|
return audioPlayer.getAudioContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化编码器
|
||||||
|
initEncoder() {
|
||||||
|
if (!this.opusEncoder) {
|
||||||
|
this.opusEncoder = initOpusEncoder();
|
||||||
|
}
|
||||||
|
return this.opusEncoder;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PCM处理器代码
|
||||||
|
getAudioProcessorCode() {
|
||||||
|
return `
|
||||||
|
class AudioRecorderProcessor extends AudioWorkletProcessor {
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.buffers = [];
|
||||||
|
this.frameSize = 960;
|
||||||
|
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;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.buffer[this.bufferIndex++] = Math.max(-32768, Math.min(32767, Math.floor(input[i] * 32767)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor('audio-recorder-processor', AudioRecorderProcessor);
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建音频处理器
|
||||||
|
async createAudioProcessor() {
|
||||||
|
this.audioContext = this.getAudioContext();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (this.audioContext.audioWorklet) {
|
||||||
|
const blob = new Blob([this.getAudioProcessorCode()], { type: 'application/javascript' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
await this.audioContext.audioWorklet.addModule(url);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
const audioProcessor = new AudioWorkletNode(this.audioContext, 'audio-recorder-processor');
|
||||||
|
|
||||||
|
audioProcessor.port.onmessage = (event) => {
|
||||||
|
if (event.data.type === 'buffer') {
|
||||||
|
this.processPCMBuffer(event.data.buffer);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log('使用AudioWorklet处理音频', 'success');
|
||||||
|
|
||||||
|
const silent = this.audioContext.createGain();
|
||||||
|
silent.gain.value = 0;
|
||||||
|
audioProcessor.connect(silent);
|
||||||
|
silent.connect(this.audioContext.destination);
|
||||||
|
return { node: audioProcessor, type: 'worklet' };
|
||||||
|
} else {
|
||||||
|
log('AudioWorklet不可用,使用ScriptProcessorNode作为回退方案', 'warning');
|
||||||
|
return this.createScriptProcessor();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`创建音频处理器失败: ${error.message},尝试回退方案`, 'error');
|
||||||
|
return this.createScriptProcessor();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建ScriptProcessor作为回退
|
||||||
|
createScriptProcessor() {
|
||||||
|
try {
|
||||||
|
const frameSize = 4096;
|
||||||
|
const scriptProcessor = this.audioContext.createScriptProcessor(frameSize, 1, 1);
|
||||||
|
|
||||||
|
scriptProcessor.onaudioprocess = (event) => {
|
||||||
|
if (!this.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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
this.processPCMBuffer(buffer);
|
||||||
|
};
|
||||||
|
|
||||||
|
const silent = this.audioContext.createGain();
|
||||||
|
silent.gain.value = 0;
|
||||||
|
scriptProcessor.connect(silent);
|
||||||
|
silent.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
log('使用ScriptProcessorNode作为回退方案成功', 'warning');
|
||||||
|
return { node: scriptProcessor, type: 'processor' };
|
||||||
|
} catch (fallbackError) {
|
||||||
|
log(`回退方案也失败: ${fallbackError.message}`, 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理PCM缓冲数据
|
||||||
|
processPCMBuffer(buffer) {
|
||||||
|
if (!this.isRecording) return;
|
||||||
|
|
||||||
|
const newBuffer = new Int16Array(this.pcmDataBuffer.length + buffer.length);
|
||||||
|
newBuffer.set(this.pcmDataBuffer);
|
||||||
|
newBuffer.set(buffer, this.pcmDataBuffer.length);
|
||||||
|
this.pcmDataBuffer = newBuffer;
|
||||||
|
|
||||||
|
const samplesPerFrame = 960;
|
||||||
|
|
||||||
|
while (this.pcmDataBuffer.length >= samplesPerFrame) {
|
||||||
|
const frameData = this.pcmDataBuffer.slice(0, samplesPerFrame);
|
||||||
|
this.pcmDataBuffer = this.pcmDataBuffer.slice(samplesPerFrame);
|
||||||
|
|
||||||
|
this.encodeAndSendOpus(frameData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码并发送Opus数据
|
||||||
|
encodeAndSendOpus(pcmData = null) {
|
||||||
|
if (!this.opusEncoder) {
|
||||||
|
log('Opus编码器未初始化', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (pcmData) {
|
||||||
|
const opusData = this.opusEncoder.encode(pcmData);
|
||||||
|
|
||||||
|
if (opusData && opusData.length > 0) {
|
||||||
|
this.audioBuffers.push(opusData.buffer);
|
||||||
|
this.totalAudioSize += opusData.length;
|
||||||
|
|
||||||
|
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||||
|
try {
|
||||||
|
this.websocket.send(opusData.buffer);
|
||||||
|
log(`发送Opus帧,大小:${opusData.length}字节`, 'debug');
|
||||||
|
} catch (error) {
|
||||||
|
log(`WebSocket发送错误: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log('Opus编码失败,无有效数据返回', 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (this.pcmDataBuffer.length > 0) {
|
||||||
|
const samplesPerFrame = 960;
|
||||||
|
if (this.pcmDataBuffer.length < samplesPerFrame) {
|
||||||
|
const paddedBuffer = new Int16Array(samplesPerFrame);
|
||||||
|
paddedBuffer.set(this.pcmDataBuffer);
|
||||||
|
this.encodeAndSendOpus(paddedBuffer);
|
||||||
|
} else {
|
||||||
|
this.encodeAndSendOpus(this.pcmDataBuffer.slice(0, samplesPerFrame));
|
||||||
|
}
|
||||||
|
this.pcmDataBuffer = new Int16Array(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`Opus编码错误: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始录音
|
||||||
|
async start() {
|
||||||
|
if (this.isRecording) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (!this.initEncoder()) {
|
||||||
|
log('无法启动录音: Opus编码器初始化失败', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log('请至少录制1-2秒钟的音频,确保采集到足够数据', 'info');
|
||||||
|
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: {
|
||||||
|
echoCancellation: true,
|
||||||
|
noiseSuppression: true,
|
||||||
|
sampleRate: 16000,
|
||||||
|
channelCount: 1
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.audioContext = this.getAudioContext();
|
||||||
|
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
await this.audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
const processorResult = await this.createAudioProcessor();
|
||||||
|
if (!processorResult) {
|
||||||
|
log('无法创建音频处理器', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioProcessor = processorResult.node;
|
||||||
|
this.audioProcessorType = processorResult.type;
|
||||||
|
|
||||||
|
this.audioSource = this.audioContext.createMediaStreamSource(stream);
|
||||||
|
this.analyser = this.audioContext.createAnalyser();
|
||||||
|
this.analyser.fftSize = 2048;
|
||||||
|
|
||||||
|
this.audioSource.connect(this.analyser);
|
||||||
|
this.audioSource.connect(this.audioProcessor);
|
||||||
|
|
||||||
|
this.pcmDataBuffer = new Int16Array();
|
||||||
|
this.audioBuffers = [];
|
||||||
|
this.totalAudioSize = 0;
|
||||||
|
this.isRecording = true;
|
||||||
|
|
||||||
|
if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) {
|
||||||
|
this.audioProcessor.port.postMessage({ command: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送监听开始消息
|
||||||
|
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||||
|
const listenMessage = {
|
||||||
|
type: 'listen',
|
||||||
|
mode: 'manual',
|
||||||
|
state: 'start'
|
||||||
|
};
|
||||||
|
|
||||||
|
log(`发送录音开始消息: ${JSON.stringify(listenMessage)}`, 'info');
|
||||||
|
this.websocket.send(JSON.stringify(listenMessage));
|
||||||
|
} else {
|
||||||
|
log('WebSocket未连接,无法发送开始消息', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始可视化
|
||||||
|
if (this.onVisualizerUpdate) {
|
||||||
|
const dataArray = new Uint8Array(this.analyser.frequencyBinCount);
|
||||||
|
this.startVisualization(dataArray);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动录音计时器
|
||||||
|
let recordingSeconds = 0;
|
||||||
|
this.recordingTimer = setInterval(() => {
|
||||||
|
recordingSeconds += 0.1;
|
||||||
|
if (this.onRecordingStart) {
|
||||||
|
this.onRecordingStart(recordingSeconds);
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
log('开始PCM直接录音', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log(`直接录音启动错误: ${error.message}`, 'error');
|
||||||
|
this.isRecording = false;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 开始可视化
|
||||||
|
startVisualization(dataArray) {
|
||||||
|
const draw = () => {
|
||||||
|
this.visualizationRequest = requestAnimationFrame(() => draw());
|
||||||
|
|
||||||
|
if (!this.isRecording) return;
|
||||||
|
|
||||||
|
this.analyser.getByteFrequencyData(dataArray);
|
||||||
|
|
||||||
|
if (this.onVisualizerUpdate) {
|
||||||
|
this.onVisualizerUpdate(dataArray);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
draw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 停止录音
|
||||||
|
stop() {
|
||||||
|
if (!this.isRecording) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.isRecording = false;
|
||||||
|
|
||||||
|
if (this.audioProcessor) {
|
||||||
|
if (this.audioProcessorType === 'worklet' && this.audioProcessor.port) {
|
||||||
|
this.audioProcessor.port.postMessage({ command: 'stop' });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.audioProcessor.disconnect();
|
||||||
|
this.audioProcessor = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.audioSource) {
|
||||||
|
this.audioSource.disconnect();
|
||||||
|
this.audioSource = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.visualizationRequest) {
|
||||||
|
cancelAnimationFrame(this.visualizationRequest);
|
||||||
|
this.visualizationRequest = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.recordingTimer) {
|
||||||
|
clearInterval(this.recordingTimer);
|
||||||
|
this.recordingTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码并发送剩余的数据
|
||||||
|
this.encodeAndSendOpus();
|
||||||
|
|
||||||
|
// 发送结束信号
|
||||||
|
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
|
||||||
|
const emptyOpusFrame = new Uint8Array(0);
|
||||||
|
this.websocket.send(emptyOpusFrame);
|
||||||
|
|
||||||
|
const stopMessage = {
|
||||||
|
type: 'listen',
|
||||||
|
mode: 'manual',
|
||||||
|
state: 'stop'
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.send(JSON.stringify(stopMessage));
|
||||||
|
log('已发送录音停止信号', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.onRecordingStop) {
|
||||||
|
this.onRecordingStop();
|
||||||
|
}
|
||||||
|
|
||||||
|
log('停止PCM直接录音', 'success');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log(`直接录音停止错误: ${error.message}`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取分析器
|
||||||
|
getAnalyser() {
|
||||||
|
return this.analyser;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例
|
||||||
|
let audioRecorderInstance = null;
|
||||||
|
|
||||||
|
export function getAudioRecorder() {
|
||||||
|
if (!audioRecorderInstance) {
|
||||||
|
audioRecorderInstance = new AudioRecorder();
|
||||||
|
}
|
||||||
|
return audioRecorderInstance;
|
||||||
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import BlockingQueue from './utils/blocking-queue.js';
|
import BlockingQueue from '../../utils/blocking-queue.js';
|
||||||
import { log } from './utils/logger.js';
|
import { log } from '../../utils/logger.js';
|
||||||
|
|
||||||
// 音频流播放上下文类
|
// 音频流播放上下文类
|
||||||
export class StreamingContext {
|
export class StreamingContext {
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
import { log } from './utils/logger.js';
|
import { log } from '../../utils/logger.js';
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// MCP 工具管理逻辑
|
// MCP 工具管理逻辑
|
||||||
@@ -23,7 +23,7 @@ export function setWebSocket(ws) {
|
|||||||
*/
|
*/
|
||||||
export async function initMcpTools() {
|
export async function initMcpTools() {
|
||||||
// 加载默认工具数据
|
// 加载默认工具数据
|
||||||
const defaultMcpTools = await fetch("js/default-mcp-tools.json").then(res => res.json());
|
const defaultMcpTools = await fetch("js/config/default-mcp-tools.json").then(res => res.json());
|
||||||
|
|
||||||
const savedTools = localStorage.getItem('mcpTools');
|
const savedTools = localStorage.getItem('mcpTools');
|
||||||
if (savedTools) {
|
if (savedTools) {
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import { otaStatusStyle } from './document.js';
|
import { otaStatusStyle } from '../../ui/dom-helper.js';
|
||||||
import { log } from './utils/logger.js';
|
import { log } from '../../utils/logger.js';
|
||||||
|
|
||||||
// WebSocket 连接
|
// WebSocket 连接
|
||||||
export async function webSocketConnect(otaUrl, config) {
|
export async function webSocketConnect(otaUrl, config) {
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
// WebSocket消息处理模块
|
||||||
|
import { log } from '../../utils/logger.js';
|
||||||
|
import { addMessage } from '../../ui/dom-helper.js';
|
||||||
|
import { webSocketConnect } from './ota-connector.js';
|
||||||
|
import { getConfig, saveConnectionUrls } from '../../config/manager.js';
|
||||||
|
import { getAudioPlayer } from '../audio/player.js';
|
||||||
|
import { getAudioRecorder } from '../audio/recorder.js';
|
||||||
|
import { getMcpTools, executeMcpTool, setWebSocket as setMcpWebSocket } from '../mcp/tools.js';
|
||||||
|
|
||||||
|
// WebSocket处理器类
|
||||||
|
export class WebSocketHandler {
|
||||||
|
constructor() {
|
||||||
|
this.websocket = null;
|
||||||
|
this.onConnectionStateChange = null;
|
||||||
|
this.onRecordButtonStateChange = null;
|
||||||
|
this.onSessionStateChange = null;
|
||||||
|
this.onSessionEmotionChange = null;
|
||||||
|
this.currentSessionId = null;
|
||||||
|
this.isRemoteSpeaking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送hello握手消息
|
||||||
|
async sendHelloMessage() {
|
||||||
|
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = getConfig();
|
||||||
|
|
||||||
|
const helloMessage = {
|
||||||
|
type: 'hello',
|
||||||
|
device_id: config.deviceId,
|
||||||
|
device_name: config.deviceName,
|
||||||
|
device_mac: config.deviceMac,
|
||||||
|
token: config.token,
|
||||||
|
features: {
|
||||||
|
mcp: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
log('发送hello握手消息', 'info');
|
||||||
|
this.websocket.send(JSON.stringify(helloMessage));
|
||||||
|
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
log('等待hello响应超时', 'error');
|
||||||
|
log('提示: 请尝试点击"测试认证"按钮进行连接排查', 'info');
|
||||||
|
resolve(false);
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.websocket.removeEventListener('message', onMessageHandler);
|
||||||
|
resolve(true);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// 忽略非JSON消息
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.addEventListener('message', onMessageHandler);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log(`发送hello消息错误: ${error.message}`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理文本消息
|
||||||
|
handleTextMessage(message) {
|
||||||
|
if (message.type === 'hello') {
|
||||||
|
log(`服务器回应:${JSON.stringify(message, null, 2)}`, 'success');
|
||||||
|
} else if (message.type === 'tts') {
|
||||||
|
this.handleTTSMessage(message);
|
||||||
|
} 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');
|
||||||
|
|
||||||
|
// 如果包含表情,更新sessionStatus表情
|
||||||
|
if (message.text && /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(message.text)) {
|
||||||
|
// 提取表情符号
|
||||||
|
const emojiMatch = message.text.match(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u);
|
||||||
|
if (emojiMatch && this.onSessionEmotionChange) {
|
||||||
|
this.onSessionEmotionChange(emojiMatch[0]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 只有当文本不仅仅是表情时,才添加到对话中
|
||||||
|
// 移除文本中的表情后检查是否还有内容
|
||||||
|
const textWithoutEmoji = message.text ? message.text.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim() : '';
|
||||||
|
if (textWithoutEmoji) {
|
||||||
|
addMessage(message.text);
|
||||||
|
}
|
||||||
|
} else if (message.type === 'mcp') {
|
||||||
|
this.handleMCPMessage(message);
|
||||||
|
} else {
|
||||||
|
log(`未知消息类型: ${message.type}`, 'info');
|
||||||
|
addMessage(JSON.stringify(message, null, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理TTS消息
|
||||||
|
handleTTSMessage(message) {
|
||||||
|
if (message.state === 'start') {
|
||||||
|
log('服务器开始发送语音', 'info');
|
||||||
|
this.currentSessionId = message.session_id;
|
||||||
|
this.isRemoteSpeaking = true;
|
||||||
|
if (this.onSessionStateChange) {
|
||||||
|
this.onSessionStateChange(true);
|
||||||
|
}
|
||||||
|
} 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');
|
||||||
|
this.isRemoteSpeaking = false;
|
||||||
|
if (this.onRecordButtonStateChange) {
|
||||||
|
this.onRecordButtonStateChange(false);
|
||||||
|
}
|
||||||
|
if (this.onSessionStateChange) {
|
||||||
|
this.onSessionStateChange(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理MCP消息
|
||||||
|
handleMCPMessage(message) {
|
||||||
|
const payload = message.payload || {};
|
||||||
|
log(`服务器下发: ${JSON.stringify(message)}`, 'info');
|
||||||
|
|
||||||
|
if (payload.method === 'tools/list') {
|
||||||
|
const tools = getMcpTools();
|
||||||
|
|
||||||
|
const replyMessage = JSON.stringify({
|
||||||
|
"session_id": message.session_id || "",
|
||||||
|
"type": "mcp",
|
||||||
|
"payload": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": payload.id,
|
||||||
|
"result": {
|
||||||
|
"tools": tools
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
log(`客户端上报: ${replyMessage}`, 'info');
|
||||||
|
this.websocket.send(replyMessage);
|
||||||
|
log(`回复MCP工具列表: ${tools.length} 个工具`, 'info');
|
||||||
|
|
||||||
|
} else if (payload.method === 'tools/call') {
|
||||||
|
const toolName = payload.params?.name;
|
||||||
|
const toolArgs = payload.params?.arguments;
|
||||||
|
|
||||||
|
log(`调用工具: ${toolName} 参数: ${JSON.stringify(toolArgs)}`, 'info');
|
||||||
|
|
||||||
|
const result = executeMcpTool(toolName, toolArgs);
|
||||||
|
|
||||||
|
const replyMessage = JSON.stringify({
|
||||||
|
"session_id": message.session_id || "",
|
||||||
|
"type": "mcp",
|
||||||
|
"payload": {
|
||||||
|
"jsonrpc": "2.0",
|
||||||
|
"id": payload.id,
|
||||||
|
"result": {
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "text",
|
||||||
|
"text": JSON.stringify(result)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isError": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log(`客户端上报: ${replyMessage}`, 'info');
|
||||||
|
this.websocket.send(replyMessage);
|
||||||
|
} else if (payload.method === 'initialize') {
|
||||||
|
log(`收到工具初始化请求: ${JSON.stringify(payload.params)}`, 'info');
|
||||||
|
} else {
|
||||||
|
log(`未知的MCP方法: ${payload.method}`, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理二进制消息
|
||||||
|
async handleBinaryMessage(data) {
|
||||||
|
try {
|
||||||
|
let arrayBuffer;
|
||||||
|
if (data instanceof ArrayBuffer) {
|
||||||
|
arrayBuffer = data;
|
||||||
|
log(`收到ArrayBuffer音频数据,大小: ${data.byteLength}字节`, 'debug');
|
||||||
|
} else if (data instanceof Blob) {
|
||||||
|
arrayBuffer = await data.arrayBuffer();
|
||||||
|
log(`收到Blob音频数据,大小: ${arrayBuffer.byteLength}字节`, 'debug');
|
||||||
|
} else {
|
||||||
|
log(`收到未知类型的二进制数据: ${typeof data}`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const opusData = new Uint8Array(arrayBuffer);
|
||||||
|
const audioPlayer = getAudioPlayer();
|
||||||
|
audioPlayer.enqueueAudioData(opusData);
|
||||||
|
} catch (error) {
|
||||||
|
log(`处理二进制消息出错: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接WebSocket服务器
|
||||||
|
async connect() {
|
||||||
|
const config = getConfig();
|
||||||
|
log('正在检查OTA状态...', 'info');
|
||||||
|
saveConnectionUrls();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const otaUrl = document.getElementById('otaUrl').value.trim();
|
||||||
|
const ws = await webSocketConnect(otaUrl, config);
|
||||||
|
if (ws === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
this.websocket = ws;
|
||||||
|
|
||||||
|
// 设置接收二进制数据的类型为ArrayBuffer
|
||||||
|
this.websocket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
// 设置 MCP 模块的 WebSocket 实例
|
||||||
|
setMcpWebSocket(this.websocket);
|
||||||
|
|
||||||
|
// 设置录音器的WebSocket
|
||||||
|
const audioRecorder = getAudioRecorder();
|
||||||
|
audioRecorder.setWebSocket(this.websocket);
|
||||||
|
|
||||||
|
this.setupEventHandlers();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log(`连接错误: ${error.message}`, 'error');
|
||||||
|
if (this.onConnectionStateChange) {
|
||||||
|
this.onConnectionStateChange(false);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置事件处理器
|
||||||
|
setupEventHandlers() {
|
||||||
|
this.websocket.onopen = async () => {
|
||||||
|
const url = document.getElementById('serverUrl').value;
|
||||||
|
log(`已连接到服务器: ${url}`, 'success');
|
||||||
|
|
||||||
|
if (this.onConnectionStateChange) {
|
||||||
|
this.onConnectionStateChange(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接成功后,默认状态为聆听中
|
||||||
|
this.isRemoteSpeaking = false;
|
||||||
|
if (this.onSessionStateChange) {
|
||||||
|
this.onSessionStateChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sendHelloMessage();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onclose = () => {
|
||||||
|
log('已断开连接', 'info');
|
||||||
|
|
||||||
|
if (this.onConnectionStateChange) {
|
||||||
|
this.onConnectionStateChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const audioRecorder = getAudioRecorder();
|
||||||
|
audioRecorder.stop();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onerror = (error) => {
|
||||||
|
log(`WebSocket错误: ${error.message || '未知错误'}`, 'error');
|
||||||
|
|
||||||
|
if (this.onConnectionStateChange) {
|
||||||
|
this.onConnectionStateChange(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
this.handleTextMessage(message);
|
||||||
|
} else {
|
||||||
|
this.handleBinaryMessage(event.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log(`WebSocket消息处理错误: ${error.message}`, 'error');
|
||||||
|
if (typeof event.data === 'string') {
|
||||||
|
addMessage(event.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 断开连接
|
||||||
|
disconnect() {
|
||||||
|
if (!this.websocket) return;
|
||||||
|
|
||||||
|
this.websocket.close();
|
||||||
|
const audioRecorder = getAudioRecorder();
|
||||||
|
audioRecorder.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送文本消息
|
||||||
|
sendTextMessage(text) {
|
||||||
|
if (text === '' || !this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 如果对方正在说话,先发送打断消息
|
||||||
|
if (this.isRemoteSpeaking && this.currentSessionId) {
|
||||||
|
const abortMessage = {
|
||||||
|
session_id: this.currentSessionId,
|
||||||
|
type: 'abort',
|
||||||
|
reason: 'wake_word_detected'
|
||||||
|
};
|
||||||
|
this.websocket.send(JSON.stringify(abortMessage));
|
||||||
|
log('发送打断消息', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
const listenMessage = {
|
||||||
|
type: 'listen',
|
||||||
|
mode: 'manual',
|
||||||
|
state: 'detect',
|
||||||
|
text: text
|
||||||
|
};
|
||||||
|
|
||||||
|
this.websocket.send(JSON.stringify(listenMessage));
|
||||||
|
log(`发送文本消息: ${text}`, 'info');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log(`发送消息错误: ${error.message}`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取WebSocket实例
|
||||||
|
getWebSocket() {
|
||||||
|
return this.websocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已连接
|
||||||
|
isConnected() {
|
||||||
|
return this.websocket && this.websocket.readyState === WebSocket.OPEN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例
|
||||||
|
let wsHandlerInstance = null;
|
||||||
|
|
||||||
|
export function getWebSocketHandler() {
|
||||||
|
if (!wsHandlerInstance) {
|
||||||
|
wsHandlerInstance = new WebSocketHandler();
|
||||||
|
}
|
||||||
|
return wsHandlerInstance;
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// UI控制模块
|
||||||
|
import { loadConfig, saveConfig } from '../config/manager.js';
|
||||||
|
import { getAudioRecorder } from '../core/audio/recorder.js';
|
||||||
|
import { getWebSocketHandler } from '../core/network/websocket.js';
|
||||||
|
|
||||||
|
// UI控制器类
|
||||||
|
export class UIController {
|
||||||
|
constructor() {
|
||||||
|
this.isEditing = false;
|
||||||
|
this.visualizerCanvas = null;
|
||||||
|
this.visualizerContext = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化
|
||||||
|
init() {
|
||||||
|
this.visualizerCanvas = document.getElementById('audioVisualizer');
|
||||||
|
this.visualizerContext = this.visualizerCanvas.getContext('2d');
|
||||||
|
|
||||||
|
this.initVisualizer();
|
||||||
|
this.initEventListeners();
|
||||||
|
loadConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化可视化器
|
||||||
|
initVisualizer() {
|
||||||
|
this.visualizerCanvas.width = this.visualizerCanvas.clientWidth;
|
||||||
|
this.visualizerCanvas.height = this.visualizerCanvas.clientHeight;
|
||||||
|
this.visualizerContext.fillStyle = '#fafafa';
|
||||||
|
this.visualizerContext.fillRect(0, 0, this.visualizerCanvas.width, this.visualizerCanvas.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态显示
|
||||||
|
updateStatusDisplay(element, text) {
|
||||||
|
element.textContent = text;
|
||||||
|
element.removeAttribute('style');
|
||||||
|
element.classList.remove('connected');
|
||||||
|
if (text.includes('已连接')) {
|
||||||
|
element.classList.add('connected');
|
||||||
|
}
|
||||||
|
console.log('更新状态:', text, '类列表:', element.className, '样式属性:', element.getAttribute('style'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新连接状态UI
|
||||||
|
updateConnectionUI(isConnected) {
|
||||||
|
const connectionStatus = document.getElementById('connectionStatus');
|
||||||
|
const otaStatus = document.getElementById('otaStatus');
|
||||||
|
const connectButton = document.getElementById('connectButton');
|
||||||
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
const sendTextButton = document.getElementById('sendTextButton');
|
||||||
|
const recordButton = document.getElementById('recordButton');
|
||||||
|
|
||||||
|
if (isConnected) {
|
||||||
|
this.updateStatusDisplay(connectionStatus, '● WS已连接');
|
||||||
|
this.updateStatusDisplay(otaStatus, '● OTA已连接');
|
||||||
|
connectButton.textContent = '断开';
|
||||||
|
messageInput.disabled = false;
|
||||||
|
sendTextButton.disabled = false;
|
||||||
|
recordButton.disabled = false;
|
||||||
|
} else {
|
||||||
|
this.updateStatusDisplay(connectionStatus, '● WS未连接');
|
||||||
|
this.updateStatusDisplay(otaStatus, '● OTA未连接');
|
||||||
|
connectButton.textContent = '连接';
|
||||||
|
messageInput.disabled = true;
|
||||||
|
sendTextButton.disabled = true;
|
||||||
|
recordButton.disabled = true;
|
||||||
|
// 断开连接时,会话状态变为离线
|
||||||
|
this.updateSessionStatus(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新录音按钮状态
|
||||||
|
updateRecordButtonState(isRecording, seconds = 0) {
|
||||||
|
const recordButton = document.getElementById('recordButton');
|
||||||
|
if (isRecording) {
|
||||||
|
recordButton.textContent = `停止录音 ${seconds.toFixed(1)}秒`;
|
||||||
|
recordButton.classList.add('recording');
|
||||||
|
} else {
|
||||||
|
recordButton.textContent = '开始录音';
|
||||||
|
recordButton.classList.remove('recording');
|
||||||
|
}
|
||||||
|
recordButton.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新会话状态UI
|
||||||
|
updateSessionStatus(isSpeaking) {
|
||||||
|
const sessionStatus = document.getElementById('sessionStatus');
|
||||||
|
if (!sessionStatus) return;
|
||||||
|
|
||||||
|
if (isSpeaking === null) {
|
||||||
|
// 离线状态
|
||||||
|
sessionStatus.innerHTML = '<span class="emoji-large">😶</span> 小智离线中';
|
||||||
|
sessionStatus.className = 'status offline';
|
||||||
|
} else if (isSpeaking) {
|
||||||
|
// 说话中
|
||||||
|
sessionStatus.innerHTML = '<span class="emoji-large">😶</span> 小智说话中';
|
||||||
|
sessionStatus.className = 'status speaking';
|
||||||
|
} else {
|
||||||
|
// 聆听中
|
||||||
|
sessionStatus.innerHTML = '<span class="emoji-large">😶</span> 小智聆听中';
|
||||||
|
sessionStatus.className = 'status listening';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新会话表情
|
||||||
|
updateSessionEmotion(emoji) {
|
||||||
|
const sessionStatus = document.getElementById('sessionStatus');
|
||||||
|
if (!sessionStatus) return;
|
||||||
|
|
||||||
|
// 获取当前文本内容,提取非表情部分
|
||||||
|
let currentText = sessionStatus.textContent;
|
||||||
|
// 移除现有的表情符号
|
||||||
|
currentText = currentText.replace(/[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/gu, '').trim();
|
||||||
|
// 使用 innerHTML 添加带样式的表情
|
||||||
|
sessionStatus.innerHTML = `<span class="emoji-large">${emoji}</span> ${currentText}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 绘制音频可视化效果
|
||||||
|
drawVisualizer(dataArray) {
|
||||||
|
this.visualizerContext.fillStyle = '#fafafa';
|
||||||
|
this.visualizerContext.fillRect(0, 0, this.visualizerCanvas.width, this.visualizerCanvas.height);
|
||||||
|
|
||||||
|
const barWidth = (this.visualizerCanvas.width / dataArray.length) * 2.5;
|
||||||
|
let barHeight;
|
||||||
|
let x = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < dataArray.length; i++) {
|
||||||
|
barHeight = dataArray[i] / 2;
|
||||||
|
|
||||||
|
this.visualizerContext.fillStyle = `rgb(${barHeight + 100}, 50, 50)`;
|
||||||
|
this.visualizerContext.fillRect(x, this.visualizerCanvas.height - barHeight, barWidth, barHeight);
|
||||||
|
|
||||||
|
x += barWidth + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化事件监听器
|
||||||
|
initEventListeners() {
|
||||||
|
const wsHandler = getWebSocketHandler();
|
||||||
|
const audioRecorder = getAudioRecorder();
|
||||||
|
|
||||||
|
// 设置WebSocket回调
|
||||||
|
wsHandler.onConnectionStateChange = (isConnected) => {
|
||||||
|
this.updateConnectionUI(isConnected);
|
||||||
|
};
|
||||||
|
|
||||||
|
wsHandler.onRecordButtonStateChange = (isRecording) => {
|
||||||
|
this.updateRecordButtonState(isRecording);
|
||||||
|
};
|
||||||
|
|
||||||
|
wsHandler.onSessionStateChange = (isSpeaking) => {
|
||||||
|
this.updateSessionStatus(isSpeaking);
|
||||||
|
};
|
||||||
|
|
||||||
|
wsHandler.onSessionEmotionChange = (emoji) => {
|
||||||
|
this.updateSessionEmotion(emoji);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 设置录音器回调
|
||||||
|
audioRecorder.onRecordingStart = (seconds) => {
|
||||||
|
this.updateRecordButtonState(true, seconds);
|
||||||
|
};
|
||||||
|
|
||||||
|
audioRecorder.onRecordingStop = () => {
|
||||||
|
this.updateRecordButtonState(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
audioRecorder.onVisualizerUpdate = (dataArray) => {
|
||||||
|
this.drawVisualizer(dataArray);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 连接按钮
|
||||||
|
const connectButton = document.getElementById('connectButton');
|
||||||
|
let isConnecting = false;
|
||||||
|
|
||||||
|
const handleConnect = async () => {
|
||||||
|
if (isConnecting) return;
|
||||||
|
|
||||||
|
if (wsHandler.isConnected()) {
|
||||||
|
wsHandler.disconnect();
|
||||||
|
} else {
|
||||||
|
isConnecting = true;
|
||||||
|
await wsHandler.connect();
|
||||||
|
isConnecting = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
connectButton.addEventListener('click', handleConnect);
|
||||||
|
|
||||||
|
// 设备配置面板编辑/确定切换
|
||||||
|
const toggleButton = document.getElementById('toggleConfig');
|
||||||
|
const deviceMacInput = document.getElementById('deviceMac');
|
||||||
|
const deviceNameInput = document.getElementById('deviceName');
|
||||||
|
const clientIdInput = document.getElementById('clientId');
|
||||||
|
const tokenInput = document.getElementById('token');
|
||||||
|
|
||||||
|
toggleButton.addEventListener('click', () => {
|
||||||
|
this.isEditing = !this.isEditing;
|
||||||
|
|
||||||
|
deviceMacInput.disabled = !this.isEditing;
|
||||||
|
deviceNameInput.disabled = !this.isEditing;
|
||||||
|
clientIdInput.disabled = !this.isEditing;
|
||||||
|
tokenInput.disabled = !this.isEditing;
|
||||||
|
|
||||||
|
toggleButton.textContent = this.isEditing ? '确定' : '编辑';
|
||||||
|
|
||||||
|
if (!this.isEditing) {
|
||||||
|
saveConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 标签页切换
|
||||||
|
const tabs = document.querySelectorAll('.tab');
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', () => {
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
|
tab.classList.add('active');
|
||||||
|
const tabContent = document.getElementById(`${tab.dataset.tab}Tab`);
|
||||||
|
tabContent.classList.add('active');
|
||||||
|
|
||||||
|
if (tab.dataset.tab === 'voice') {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.initVisualizer();
|
||||||
|
}, 50);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// 发送文本消息
|
||||||
|
const messageInput = document.getElementById('messageInput');
|
||||||
|
const sendTextButton = document.getElementById('sendTextButton');
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
const message = messageInput.value.trim();
|
||||||
|
if (message && wsHandler.sendTextMessage(message)) {
|
||||||
|
messageInput.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
sendTextButton.addEventListener('click', sendMessage);
|
||||||
|
messageInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter') sendMessage();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 录音按钮
|
||||||
|
const recordButton = document.getElementById('recordButton');
|
||||||
|
recordButton.addEventListener('click', () => {
|
||||||
|
if (audioRecorder.isRecording) {
|
||||||
|
audioRecorder.stop();
|
||||||
|
} else {
|
||||||
|
audioRecorder.start();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 窗口大小变化
|
||||||
|
window.addEventListener('resize', () => this.initVisualizer());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建单例
|
||||||
|
let uiControllerInstance = null;
|
||||||
|
|
||||||
|
export function getUIController() {
|
||||||
|
if (!uiControllerInstance) {
|
||||||
|
uiControllerInstance = new UIController();
|
||||||
|
}
|
||||||
|
return uiControllerInstance;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getLogContainer } from '../document.js'
|
import { getLogContainer } from '../ui/dom-helper.js'
|
||||||
|
|
||||||
const logContainer = getLogContainer();
|
const logContainer = getLogContainer();
|
||||||
// 日志记录函数
|
// 日志记录函数
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user