Coze-JS WsChatClient 实时语音对话源码解析

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 音频编解码器枚举(如 pcmopus),决定音频数据的压缩/传输格式
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 获取所有音频输入设备,同时处理无标签设备的显示问题;
  • 状态联动:降噪开关启用/禁用时,同步控制降噪等级下拉框的可用状态,提升用户体验;
  • 错误处理:设备切换失败时恢复原选择并显示错误信息,增强鲁棒性。
相关推荐
To_OC3 小时前
别再串行写 await 了,Promise.all 才是并行请求的正确打开方式
前端·javascript·promise
vipbic3 小时前
中后台越做越乱后,我用插件化把它救回来了
前端·vue.js
Hyyy3 小时前
Computer Use 适合做什么,不适合做什么——一次真实使用后的思考
前端
小和尚同志4 小时前
前端 AI 单元测试思考与落地
前端·人工智能·aigc
invicinble5 小时前
c端系统,其实更像一个信息展示平台
前端
李姆斯6 小时前
管理是否可以被完全量化
前端·产品经理·团队管理
名字还没想好☜6 小时前
Next.js 中间件实战:鉴权、重定向与 A/B 分流
开发语言·前端·javascript·中间件·react·next.js
广州灵眸科技有限公司6 小时前
瑞芯微RV1126B开发板(EASY-EAI-PI2) INI文件操作
java·前端·javascript·网络·人工智能
江华森7 小时前
Web 开发基础:HTTP 与 REST
前端·网络协议·http
IT_陈寒8 小时前
Redis的KEYS命令把我搞崩溃了,改用SCAN才活过来
前端·人工智能·后端