feat: 初始化前端项目,搭建 React + TypeScript + Vite 工程结构
- 创建 Vite + React 18 + TypeScript 项目(严格模式) - 安装 @ricky0123/vad-web、onnxruntime-web、uuid 依赖 - 定义全部 WebSocket 消息类型和数据模型(对齐接口文档) - 实现 WebSocket 连接管理(心跳保活、指数退避重连) - 实现音频编码工具(PCM↔Base64、DataURL 转换) - 创建组件骨架:CameraManager、MicManager、EdgeProcessor、WebSocketManager、ChatPanel、VideoPreview - 实现核心 useVisionSession Hook 骨架 - 配置 ESLint(_前缀变量忽略规则) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
50
frontend/src/lib/audio.ts
Normal file
50
frontend/src/lib/audio.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
// ============================================================
|
||||
// 音频编码工具
|
||||
// 职责:将浏览器采集的音频数据编码为 Base64 PCM 格式
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 将 Float32Array 音频样本编码为 Base64 PCM 16kHz 字符串
|
||||
* 用于 WebSocket query 消息的 audio 字段
|
||||
*/
|
||||
export function encodeAudioToBase64(samples: Float32Array): string {
|
||||
// Float32 -> Int16 PCM
|
||||
const buffer = new ArrayBuffer(samples.length * 2);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
view.setInt16(i * 2, s < 0 ? s * 0x8000 : s * 0x7fff, true);
|
||||
}
|
||||
|
||||
// ArrayBuffer -> Base64
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 Base64 音频数据解码为可用于播放的 Blob URL
|
||||
* 用于 TTS 音频播放
|
||||
*/
|
||||
export function decodeBase64Audio(base64: string, mimeType: string): string {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
const blob = new Blob([bytes], { type: mimeType });
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 JPEG DataURL 转换为纯 Base64(去掉 data:image/jpeg;base64, 前缀)
|
||||
* 用于 WebSocket query 消息的 image 字段
|
||||
*/
|
||||
export function dataUrlToBase64(dataUrl: string): string {
|
||||
const commaIndex = dataUrl.indexOf(",");
|
||||
return commaIndex >= 0 ? dataUrl.substring(commaIndex + 1) : dataUrl;
|
||||
}
|
||||
149
frontend/src/lib/websocket.ts
Normal file
149
frontend/src/lib/websocket.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// ============================================================
|
||||
// WebSocket 连接管理
|
||||
// 职责:心跳保活、指数退避重连、类型安全的消息收发
|
||||
// 来源:docs/03-接口文档.md §六 连接管理
|
||||
// ============================================================
|
||||
|
||||
import type { ClientMessage, ServerMessage, WsMessage } from "../types";
|
||||
|
||||
const WS_URL = "ws://localhost:8080/ws";
|
||||
const PING_INTERVAL = 30_000; // 30 秒心跳
|
||||
const MAX_RECONNECT_DELAY = 30_000; // 最大重连延迟 30 秒
|
||||
|
||||
type MessageHandler = (msg: ServerMessage) => void;
|
||||
type StatusHandler = (status: ConnectionStatus) => void;
|
||||
|
||||
export type ConnectionStatus = "connecting" | "connected" | "disconnected";
|
||||
|
||||
export class CamTalkWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconnectAttempt = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private shouldReconnect = true;
|
||||
|
||||
private messageHandlers = new Set<MessageHandler>();
|
||||
private statusHandlers = new Set<StatusHandler>();
|
||||
private _status: ConnectionStatus = "disconnected";
|
||||
|
||||
get status(): ConnectionStatus {
|
||||
return this._status;
|
||||
}
|
||||
|
||||
/** 注册消息回调 */
|
||||
onMessage(handler: MessageHandler): () => void {
|
||||
this.messageHandlers.add(handler);
|
||||
return () => this.messageHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** 注册连接状态回调 */
|
||||
onStatusChange(handler: StatusHandler): () => void {
|
||||
this.statusHandlers.add(handler);
|
||||
return () => this.statusHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** 建立连接 */
|
||||
connect(): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) return;
|
||||
|
||||
this.shouldReconnect = true;
|
||||
this.setStatus("connecting");
|
||||
|
||||
const ws = new WebSocket(WS_URL);
|
||||
|
||||
ws.onopen = () => {
|
||||
this.reconnectAttempt = 0;
|
||||
this.setStatus("connected");
|
||||
this.startPing();
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data) as WsMessage;
|
||||
// 忽略 pong,心跳由服务端自动回复
|
||||
if (msg.type === "pong") return;
|
||||
this.messageHandlers.forEach((h) => h(msg as ServerMessage));
|
||||
} catch {
|
||||
console.error("[WS] 无法解析消息:", event.data);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
this.stopPing();
|
||||
this.setStatus("disconnected");
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.error("[WS] 连接错误:", err);
|
||||
ws.close();
|
||||
};
|
||||
|
||||
this.ws = ws;
|
||||
}
|
||||
|
||||
/** 断开连接,不再自动重连 */
|
||||
disconnect(): void {
|
||||
this.shouldReconnect = false;
|
||||
this.clearTimers();
|
||||
this.ws?.close();
|
||||
this.ws = null;
|
||||
this.setStatus("disconnected");
|
||||
}
|
||||
|
||||
/** 发送客户端消息 */
|
||||
send(msg: ClientMessage): void {
|
||||
if (this.ws?.readyState !== WebSocket.OPEN) {
|
||||
console.warn("[WS] 连接未就绪,消息丢弃:", msg.type);
|
||||
return;
|
||||
}
|
||||
this.ws.send(JSON.stringify(msg));
|
||||
}
|
||||
|
||||
/** 发送 ping 心跳 */
|
||||
private startPing(): void {
|
||||
this.stopPing();
|
||||
this.pingTimer = setInterval(() => {
|
||||
this.send({ type: "ping" });
|
||||
}, PING_INTERVAL);
|
||||
}
|
||||
|
||||
private stopPing(): void {
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 指数退避 + 抖动重连 */
|
||||
private scheduleReconnect(): void {
|
||||
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt), MAX_RECONNECT_DELAY);
|
||||
const jitter = Math.random() * 1000;
|
||||
const totalDelay = delay + jitter;
|
||||
|
||||
console.log(`[WS] ${totalDelay.toFixed(0)}ms 后重连 (attempt ${this.reconnectAttempt})`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectAttempt++;
|
||||
this.connect();
|
||||
}, totalDelay);
|
||||
}
|
||||
|
||||
private setStatus(status: ConnectionStatus): void {
|
||||
this._status = status;
|
||||
this.statusHandlers.forEach((h) => h(status));
|
||||
}
|
||||
|
||||
private clearTimers(): void {
|
||||
this.stopPing();
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 创建单例实例 */
|
||||
export const wsClient = new CamTalkWebSocket();
|
||||
Reference in New Issue
Block a user