WsChatClient 源码解析与实战指南
WsChatClient
是一个基于 WebSocket 的实时语音交互客户端 ,核心能力是整合音频录制(PcmRecorder
)、音频播放(WavStreamPlayer
)、WebSocket 通信三大模块,实现"麦克风输入→降噪处理→实时推流→服务端响应→音频播放"的端到端语音交互流程,适用于智能客服、语音助手、实时语音对话等场景。
一、核心架构与依赖
1.1 技术栈与核心依赖
依赖模块/工具 | 用途描述 |
---|---|
BaseWsChatClient |
父类,封装 WebSocket 基础通信能力(连接建立、消息发送/接收、事件监听) |
PcmRecorder |
音频录制核心,支持 PCM 格式采集、AI 降噪、多设备切换 |
WavStreamPlayer |
音频播放工具,支持 PCM/Opus 等格式的实时流播放、本地回环(监听自己的声音) |
uuid |
生成唯一 ID,用于标识 WebSocket 消息、事件等 |
工具函数(getAudioDevices 等) |
音频设备管理、对象路径赋值、系统判断(如鸿蒙系统检测) |
1.2 核心数据类型
类型名 | 用途描述 |
---|---|
WsChatClientOptions |
客户端初始化配置,包含音频采集参数(audioCaptureConfig )、AI 降噪配置(aiDenoisingConfig )、调试开关(debug )等 |
AudioRecordEvent |
音频录制事件,用于传递录制完成的 WAV 文件(如调试时的音频 dump) |
ChatUpdateEvent |
WebSocket 初始化消息,包含音频编解码格式、判停模式(turn_detection )、采样率等核心配置 |
AudioCodec |
音频编解码器枚举(如 pcm 、opus ),决定音频数据的压缩/传输格式 |
TurnDetectionType |
语音判停模式(server_vad 服务端检测、client_interrupt 客户端手动打断) |
二、核心功能模块解析
2.1 初始化流程(constructor)
ts
constructor(config: WsChatClientOptions) {
super(config); // 调用父类 BaseWsChatClient 初始化 WebSocket 基础配置
// 1. 初始化音频播放器(WavStreamPlayer)
const isMobilePlayer = config.enableLocalLoopback ?? isHarmonOS(); // 适配鸿蒙系统
this.wavStreamPlayer = new WavStreamPlayer({
sampleRate: 24000, // 默认播放采样率(与多数语音服务兼容)
enableLocalLoopback: isMobilePlayer, // 是否开启本地回环(移动端默认开启)
volume: config.playbackVolumeDefault ?? 1, // 默认播放音量(0-1)
});
// 2. 初始化音频录制器(PcmRecorder)
this.recorder = new PcmRecorder({
audioCaptureConfig: config.audioCaptureConfig, // 音频采集参数(如采样率、声道数)
aiDenoisingConfig: config.aiDenoisingConfig, // AI 降噪配置(如模式、等级)
mediaStreamTrack: config.mediaStreamTrack, // 外部传入的媒体流(可选)
wavRecordConfig: config.wavRecordConfig, // WAV 录制参数(如是否保存本地文件)
debug: config.debug, // 调试开关
deviceId: config.deviceId, // 默认麦克风设备 ID
});
// 3. 初始化静音状态(默认不静音)
this.isMuted = config.audioMutedDefault ?? false;
}
- 核心初始化逻辑:先通过父类建立 WebSocket 基础能力,再初始化"录制-播放"双核心组件,同时适配移动端(如鸿蒙系统)的本地回环需求。
2.2 实时语音录制与推流(startRecord)
startRecord
是客户端的核心方法,负责启动音频录制、处理 PCM 数据,并通过 WebSocket 实时推送到服务端:
ts
async startRecord() {
// 1. 状态检查:避免重复录制
if (this.recorder.getStatus() === 'recording') {
console.warn('Recorder is already recording');
return;
}
// 2. 客户端判停模式下,先打断当前播放(避免混音)
if (this.turnDetection === 'client_interrupt') {
this.interrupt();
}
// 3. 启动录制器(指定输入音频编解码器,默认 PCM)
await this.recorder.start(this.inputAudioCodec);
// 4. 本地回环配置:将麦克风原始输入传递给播放器,实现"听自己的声音"
const rawMediaStream = this.recorder.getRawMediaStream();
if (!rawMediaStream) {
throw new Error('无法获取原始麦克风输入');
}
this.wavStreamPlayer?.setMediaStream(rawMediaStream);
// 5. 开始录制并处理音频数据
await this.recorder.record({
// 5.1 PCM 数据回调:实时推流到服务端
pcmAudioCallback: data => {
const { raw } = data; // raw 为 PCM 原始 ArrayBuffer 数据
// 转换 ArrayBuffer 为 Base64(便于 WebSocket 传输文本格式)
const base64String = btoa(
Array.from(new Uint8Array(raw))
.map(byte => String.fromCharCode(byte))
.join(''),
);
// 通过 WebSocket 发送音频数据
this.ws?.send({
id: uuid(), // 唯一消息 ID(用于追踪或重发)
event_type: WebsocketsEventType.INPUT_AUDIO_BUFFER_APPEND, // 推流事件类型
data: { delta: base64String }, // 音频数据(Base64 编码)
});
},
// 5.2 WAV 数据回调:录制完成后生成 WAV 文件(调试/存档用)
wavAudioCallback: (blob, name) => {
const event: AudioRecordEvent = {
event_type: ClientEventType.AUDIO_INPUT_DUMP,
data: { name, wav: blob }, // blob 为 WAV 文件二进制流
};
this.emit(WsChatEventNames.AUDIO_INPUT_DUMP, event); // 触发事件,供外部处理(如下载)
},
// 5.3 调试用音频 dump 回调(与 wavAudioCallback 功能类似,视需求扩展)
dumpAudioCallback: (blob, name) => {
// 逻辑同 wavAudioCallback
},
});
}
-
关键逻辑:
- 数据转换:PCM 原始二进制(ArrayBuffer)→ Base64 字符串,解决 WebSocket 传输二进制的兼容性问题;
- 本地回环:通过
setMediaStream
将麦克风输入实时播放,提升用户交互体验(如确认自己的声音已被采集); - 事件驱动:通过
emit
触发音频 dump 事件,便于外部扩展(如下载录制文件、上传存档)。
2.3 WebSocket 连接与初始化(connect)
connect
方法负责建立 WebSocket 连接、同步服务端配置(如编解码格式、判停模式),并启动录制:
ts
async connect({ chatUpdate }: { chatUpdate?: ChatUpdateEvent } = {}) {
// 1. 初始化 WebSocket 连接(父类 BaseWsChatClient 方法)
const ws = await this.init();
this.ws = ws;
// 2. 构建初始化消息(ChatUpdateEvent):同步客户端与服务端配置
const event: ChatUpdateEvent = {
id: chatUpdate?.id || uuid(), // 消息 ID(外部传入或自动生成)
event_type: WebsocketsEventType.CHAT_UPDATE, // 配置同步事件类型
data: {
// 输入音频配置(默认 PCM 格式)
input_audio: { format: 'pcm', codec: 'pcm' },
// 输出音频配置(默认 PCM 格式,采样率 24000Hz)
output_audio: { codec: 'pcm', pcm_config: { sample_rate: 24000 } },
// 语音判停模式(默认服务端 VAD 检测)
turn_detection: { type: 'server_vad' },
need_play_prologue: true, // 是否播放启动提示音(如"您好,有什么可以帮您?")
...chatUpdate?.data, // 合并外部传入的配置(覆盖默认值)
},
};
// 3. 补充配置:语音 ID(如指定服务端返回的语音音色)
if (this.config.voiceId) {
setValueByPath(event, 'data.output_audio.voice_id', this.config.voiceId);
}
// 4. 输入音频采样率配置(默认 48000Hz,覆盖外部传入值)
setValueByPath(
event,
'data.input_audio.sample_rate',
event.data?.input_audio?.sample_rate || 48000,
);
// 5. 解析配置:更新客户端编解码、采样率等核心参数
this.inputAudioCodec = event.data?.input_audio?.codec || 'pcm'; // 输入编解码器
this.outputAudioCodec = event.data?.output_audio?.codec || 'pcm'; // 输出编解码器
if (this.outputAudioCodec === 'opus') {
// 若输出为 Opus 格式,初始化 Opus 解码器
this.outputAudioSampleRate = event.data?.output_audio?.opus_config?.sample_rate || 24000;
await this.initOpusDecoder();
} else {
// 若为 PCM 格式,直接设置采样率
this.outputAudioSampleRate = event.data?.output_audio?.pcm_config?.sample_rate || 24000;
}
// 6. 同步播放器配置(格式、采样率)
this.wavStreamPlayer?.setDefaultFormat(
(event.data?.output_audio?.codec as AudioFormat) || 'pcm',
);
this.wavStreamPlayer?.setSampleRate(this.outputAudioSampleRate);
// 7. 同步句子同步器配置(确保音频播放与文本同步,如字幕)
this.sentenceSynchronizer.setOutputAudioConfig(
this.outputAudioSampleRate,
this.outputAudioCodec,
);
// 8. 语音判停模式配置:客户端手动打断模式下,关闭本地回环(避免混音)
this.turnDetection = event.data?.turn_detection?.type || 'server_vad';
if (this.turnDetection === 'client_interrupt') {
this.wavStreamPlayer?.setLocalLoopbackEnable(false);
}
// 9. 发送初始化消息到服务端
this.ws.send(event);
// 10. 启动录制:非静音且非客户端打断模式下,自动开始录制
if (!this.isMuted && this.turnDetection !== 'client_interrupt') {
await this.startRecord();
}
// 11. 触发连接成功事件,供外部处理(如更新 UI 状态)
this.emit(WsChatEventNames.CONNECTED, event);
this.isConnected = true;
}
- 核心作用 :作为客户端与服务端的"配置桥梁",通过
ChatUpdateEvent
同步编解码、采样率、判停模式等关键参数,确保双方数据格式兼容;同时根据配置自动初始化解码器、播放器,为后续交互做好准备。
2.4 语音交互控制(关键辅助方法)
2.4.1 停止录制(stopRecord)
ts
stopRecord() {
// 状态检查:非录制状态下不执行
if (this.recorder.getStatus() !== 'recording') {
console.warn('Recorder is not recording');
return;
}
// 销毁录制器(释放麦克风资源)
this.recorder.destroy();
// 通知服务端:输入音频已完成(停止服务端监听)
this.ws?.send({
id: Date.now().toString(), // 用时间戳作为消息 ID(简单唯一)
event_type: WebsocketsEventType.INPUT_AUDIO_BUFFER_COMPLETE,
});
}
2.4.2 打断对话(interrupt)
适用于"客户端手动打断服务端响应"场景(如用户中途改变需求):
ts
interrupt() {
// 通知服务端:取消当前对话
this.ws?.send({
id: uuid(),
event_type: WebsocketsEventType.CONVERSATION_CHAT_CANCEL,
});
// 触发打断事件,供外部处理(如更新 UI 为"等待用户输入")
this.emit(WsChatEventNames.INTERRUPTED, undefined);
}
2.4.3 音频设备切换(setAudioInputDevice)
支持动态切换麦克风设备(如从内置麦克风切换到耳机麦克风):
ts
async setAudioInputDevice(deviceId: string) {
// 1. 停止当前录制(切换设备前需释放资源)
if (this.recorder.getStatus() !== 'ended') {
await this.recorder.destroy();
}
// 2. 获取所有音频设备
const devices = await getAudioDevices();
// 3. 处理默认设备(deviceId 为 'default' 时,使用系统默认麦克风)
if (deviceId === 'default') {
this.recorder.config.deviceId = undefined;
} else {
// 4. 校验设备 ID 有效性
const device = devices.audioInputs.find(d => d.deviceId === deviceId);
if (!device) {
throw new Error(`Device with id ${deviceId} not found`);
}
this.recorder.config.deviceId = device.deviceId;
}
// 5. 非静音状态下,重新启动录制
if (!this.isMuted) {
await this.startRecord();
}
// 6. 触发设备切换事件,供外部更新 UI(如刷新设备列表选中状态)
this.emit(WsChatEventNames.AUDIO_INPUT_DEVICE_CHANGED, undefined);
}
2.4.4 AI 降噪控制(setDenoiserEnabled/Level/Mode)
支持动态调整 AI 降噪功能(适应不同噪声场景,如办公室、街道):
ts
// 启用/禁用降噪
setDenoiserEnabled(enabled: boolean) {
this.recorder.setDenoiserEnabled(enabled);
// 触发降噪状态事件
this.emit(enabled ? WsChatEventNames.DENOISER_ENABLED : WsChatEventNames.DENOISER_DISABLED, undefined);
}
// 设置降噪等级(如 low/medium/high)
setDenoiserLevel(level: AIDenoiserProcessorLevel) {
this.log('setDenoiserLevel', level);
this.recorder.setDenoiserLevel(level);
}
// 设置降噪模式(如 speech_only:只保留人声;all:去除所有噪声)
setDenoiserMode(mode: AIDenoiserProcessorMode) {
this.log('setDenoiserMode', mode);
this.recorder.setDenoiserMode(mode);
}
2.5 断开连接与资源释放(disconnect)
ts
async disconnect() {
// 1. 通知服务端:取消当前对话
this.ws?.send({
id: uuid(),
event_type: WebsocketsEventType.CONVERSATION_CHAT_CANCEL,
});
// 2. 释放核心资源:录制器、播放器、Opus 解码器
await this.recorder?.destroy();
await this.wavStreamPlayer?.destroy();
await this.opusDecoder?.destroy();
// 3. 触发断开连接事件
this.emit(WsChatEventNames.DISCONNECTED, undefined);
// 4. 延迟释放(确保服务端收到消息)
await new Promise(resolve => setTimeout(resolve, 500));
// 5. 清理事件监听、关闭 WebSocket
this.listeners.clear();
this.closeWs();
this.isConnected = false;
}
- 关键注意点:资源释放需按顺序执行(先通知服务端,再释放本地资源),避免服务端因未收到"取消"消息而持续处理;延迟 500ms 是为了确保 WebSocket 消息已发送完成。
三、实战场景与集成示例
3.1 基础集成:初始化客户端并启动交互
ts
// 1. 初始化 WsChatClient 配置
const clientConfig: WsChatClientOptions = {
wsUrl: 'wss://your-websocket-server.com/chat', // WebSocket 服务端地址
debug: true, // 开启调试日志
audioMutedDefault: false, // 默认不静音
aiDenoisingConfig: {
enabled: true, // 启用 AI 降噪
level: 'medium', // 降噪等级:中等
mode: 'speech_only', // 只保留人声
},
playbackVolumeDefault: 0.8, // 默认播放音量(0.8倍)
};
// 2. 创建 WsChatClient 实例
const chatClient = new WsChatClient(clientConfig);
// 3. 监听核心事件(更新 UI 或业务逻辑)
chatClient.on(WsChatEventNames.CONNECTED, (event) => {
console.log('WebSocket 连接成功', event);
document.getElementById('status').textContent = '已连接,可开始对话';
});
chatClient.on(WsChatEventNames.AUDIO_INPUT_DUMP, (event) => {
// 下载录制的 WAV 文件(调试用)
const a = document.createElement('a');
a.href = URL.createObjectURL(event.data.wav);
a.download = event.data.name;
a.click();
URL.revokeObjectURL(a.href);
});
chatClient.on(WsChatEventNames.INTERRUPTED, () => {
console.log('对话已打断');
document.getElementById('status').textContent = '等待用户输入';
});
chatClient.on(WsChatEventNames.DISCONNECTED, () => {
console.log('WebSocket 已断开');
document.getElementById('status').textContent = '已断开连接';
});
// 4. 连接服务端并启动交互
document.getElementById('connect-btn').addEventListener('click', async () => {
try {
await chatClient.connect({
chatUpdate: {
data: {
turn_detection: { type: 'server_vad' }, // 使用服务端 VAD 判停
output_audio: { codec: 'opus' }, // 服务端输出 Opus 格式(节省带宽)
},
},
});
} catch (err) {
console.error('连接失败', err);
document.getElementById('status').textContent = '连接失败,请重试';
}
});
// 5. 断开连接
document.getElementById('disconnect-btn').addEventListener('click', async () => {
await chatClient.disconnect();
});
3.2 场景二:客户端手动打断模式(按键说话)
适用于需要用户主动控制说话时机的场景(如对讲机式交互),需将 turn_detection
设为 client_interrupt
,并通过按钮控制录制启停:
ts
// 1. 连接时配置客户端打断模式
document.getElementById('connect-btn').addEventListener('click', async () => {
await chatClient.connect({
chatUpdate: {
data: {
turn_detection: { type: 'client_interrupt' }, // 客户端手动打断
input_audio: { codec: 'pcm', sample_rate: 48000 },
},
},
});
});
// 2. 按住按钮说话(启动录制)
document.getElementById('hold-talk-btn').addEventListener('mousedown', async () => {
try {
await chatClient.startRecord();
document.getElementById('talk-status').textContent = '正在录音...';
} catch (err) {
console.error('启动录制失败', err);
}
});
// 3. 松开按钮停止说话(停止录制)
document.getElementById('hold-talk-btn').addEventListener('mouseup', async () => {
chatClient.stopRecord();
document.getElementById('talk-status').textContent = '录音已停止';
});
// 4. 点击按钮打断服务端响应
document.getElementById('interrupt-btn').addEventListener('click', () => {
chatClient.interrupt();
});
3.3 场景三:动态切换音频设备与降噪配置
实现麦克风设备下拉选择、降噪开关与等级调节的 UI 交互逻辑:
ts
// 1. 加载音频设备列表到下拉框
async function loadAudioDevices() {
const devices = await getAudioDevices();
const deviceSelect = document.getElementById('mic-select');
if (!deviceSelect) return;
// 清空现有选项
deviceSelect.innerHTML = '';
// 添加默认设备选项
const defaultOption = document.createElement('option');
defaultOption.value = 'default';
defaultOption.textContent = '默认麦克风';
deviceSelect.appendChild(defaultOption);
// 添加所有可用麦克风设备
devices.audioInputs.forEach(dev => {
const option = document.createElement('option');
option.value = dev.deviceId;
option.textContent = dev.label || `麦克风 ${dev.deviceId.slice(0, 8)}`; // 处理无标签设备
deviceSelect.appendChild(option);
});
}
// 页面加载时初始化设备列表
window.addEventListener('load', async () => {
await loadAudioDevices();
// 初始化降噪控件状态
const denoiserSwitch = document.getElementById('denoiser-switch') as HTMLInputElement;
const denoiserLevel = document.getElementById('denoiser-level') as HTMLSelectElement;
// 若初始启用降噪,设置开关为开启状态
denoiserSwitch.checked = chatClient.recorder.config.aiDenoisingConfig?.enabled ?? false;
// 同步降噪等级下拉框
denoiserLevel.value = chatClient.recorder.config.aiDenoisingConfig?.level ?? 'medium';
});
// 2. 切换麦克风设备
document.getElementById('mic-select').addEventListener('change', async (e) => {
const target = e.target as HTMLSelectElement;
const deviceId = target.value;
try {
// 显示加载状态
target.disabled = true;
document.getElementById('device-status').textContent = '切换中...';
await chatClient.setAudioInputDevice(deviceId);
// 切换成功更新状态
document.getElementById('device-status').textContent = `当前设备:${target.options[target.selectedIndex].text}`;
} catch (err) {
console.error('设备切换失败', err);
document.getElementById('device-status').textContent = `切换失败:${(err as Error).message}`;
// 恢复原选择
target.value = chatClient.recorder.config.deviceId || 'default';
} finally {
target.disabled = false;
}
});
// 3. 切换降噪功能开关
document.getElementById('denoiser-switch').addEventListener('change', (e) => {
const enabled = (e.target as HTMLInputElement).checked;
chatClient.setDenoiserEnabled(enabled);
// 联动控制降噪等级下拉框的禁用状态
const denoiserLevel = document.getElementById('denoiser-level') as HTMLSelectElement;
denoiserLevel.disabled = !enabled;
document.getElementById('denoiser-status').textContent = enabled ? '已启用' : '已禁用';
});
// 4. 调节降噪等级
document.getElementById('denoiser-level').addEventListener('change', (e) => {
const level = (e.target as HTMLSelectElement).value as AIDenoiserProcessorLevel;
chatClient.setDenoiserLevel(level);
document.getElementById('denoiser-level-status').textContent = level;
});
对应的 HTML 结构示例(供参考):
ts
<div class="audio-control">
<div class="device-selector">
<label>麦克风设备:</label>
<select id="mic-select"></select>
<span id="device-status">未选择</span>
</div>
<div class="denoiser-control">
<label>AI 降噪:</label>
<input type="checkbox" id="denoiser-switch">
<span id="denoiser-status">已禁用</span>
<label>降噪等级:</label>
<select id="denoiser-level" disabled>
<option value="low">低</option>
<option value="medium" selected>中</option>
<option value="high">高</option>
</select>
<span id="denoiser-level-status">medium</span>
</div>
</div>
关键说明:
- 设备加载逻辑:通过
getAudioDevices
获取所有音频输入设备,同时处理无标签设备的显示问题; - 状态联动:降噪开关启用/禁用时,同步控制降噪等级下拉框的可用状态,提升用户体验;
- 错误处理:设备切换失败时恢复原选择并显示错误信息,增强鲁棒性。