深入解析 WebSocket 语音交互:状态流转、表情联动与音频上下行处理实践

引言

做一个"能说、能听、还能实时反馈状态"的语音交互页面,看起来像是把 WebSocket 接上就行,但真正落到工程实现里,复杂度远不止"发文本、收文本"这么简单。

在这个项目里,WebSocket 承担了两类完全不同的数据:

  • 文本控制消息 :如 hellolistensttttsllm
  • 二进制音频帧:上行 Opus 音频、下行 Opus 音频

与此同时,前端不仅要管理连接状态,还要在 UI 上实时表达"当前到底是正在连接、正在听、正在思考、还是正在说话"。这就是为什么代码里同时出现了:

  • VoiceSessionController:负责协议状态机与上下行路由
  • VoiceAudioEngine:负责采集、编码、解码、播放
  • WebSocketVoiceWsGateway:负责 WS 收发
  • EmotionAvatar / EmotionState:负责表情和状态的可视化表达

本文就基于项目中的 voice_ws 相关代码,系统梳理这一套前后端"简单语音交互"是如何跑起来的,重点会覆盖:

  • WebSocket 连接与握手状态管理
  • 表情状态如何跟随连接、识别、播报过程变化
  • 前端语音数据如何采集、拼接、编码、发送、解码、播放
  • 从协议与消息结构可以推断出的后端职责:ASR、LLM、TTS 和 Opus 二进制下发

功能概述

这套语音交互链路,本质上是一条"控制消息 + 音频流"并行工作的通道。

从用户视角看,完整流程非常自然:

  1. 前端建立 WebSocket 连接
  2. 发送 hello 协商音频参数
  3. 连接就绪后,开始 listen.start
  4. 前端持续上传 Opus 音频帧
  5. 后端完成 ASR,返回 stt
  6. 后端完成 LLM 推理,返回 llm
  7. 后端开始 TTS,发送 tts.start
  8. 后端持续下发 Opus 二进制音频
  9. 前端解码、排队、播放
  10. 播放结束后收到 tts.stop,页面回到待命状态

如果用户希望在播报过程中立即打断,还会多出一条分支:

  1. 用户点击打断按钮,或业务层判定需要中断当前播报
  2. 前端立即执行 abort
  3. 本地播放器先停播并清理队列,再向服务端发送 {"type":"abort"}
  4. 服务端停止当前 TTS 下发,并以 tts.stop 结束本轮回复

可以用一张图概括:
VoiceAudioEngine Backend WebSocketGateway VoiceSessionController Flutter UI VoiceAudioEngine Backend WebSocketGateway VoiceSessionController Flutter UI connect(url, headers) 建立 WebSocket send hello(audio_params) hello(session_id, audio_params) text: hello reconfigure(audio_params) startListen() send listen.start uplink Opus frame send binary opus text: stt stt text: llm llm text: tts.start tts.start binary opus chunks binary frames enqueueRemoteAudio() text: tts.stop tts.stop drain + stop playback


技术栈与依赖

从代码实现看,这套链路主要依赖如下:

  • Flutter / Dart:整体业务逻辑、状态管理与 UI 展示
  • web_socket_channel:WebSocket 收发
  • record:麦克风采集
  • opus_dart:Opus 编解码
  • flutter_pcm_player:下行 PCM 播放
  • audio_session:音频会话管理
  • permission_handler:麦克风权限申请
  • provider:页面内状态辅助
  • Android 原生采集插件:可选的系统采集与音频前处理能力

对应的关键代码模块大致可以分为四层:
voice_ws_demo_screen
VoiceSessionController
WebSocketVoiceWsGateway
DefaultVoiceAudioEngine
FrameAssembler
Opus Encoder/Decoder
EmotionAvatar / EmotionState


协议模型:文本走控制,二进制走音频

这一套 WebSocket 协议非常清晰:文本帧负责状态和语义,二进制帧负责真正的声音数据

voice_ws_models.dart 中定义了几个核心消息模型:

  • HelloRequest / HelloResponse
  • ListenMessage
  • SttMessage
  • TtsMessage
  • LlmMessage

例如,前端发起握手时会发送:

dart 复制代码
Map<String, dynamic> toJson() => <String, dynamic>{
  'type': 'hello',
  'audio_params': audioParams.toJson(),
};

开始录音则发送:

dart 复制代码
ListenMessage(
  state: ListenState.start,
  mode: mode,
).toJson(sessionId: _snapshot.sessionId)

而后端回来的文本消息会按 type 路由:

dart 复制代码
switch (type) {
  case 'hello':
    _handleHello(json);
    break;
  case 'stt':
    _handleStt(json);
    break;
  case 'tts':
    _handleTts(json);
    break;
  case 'llm':
    _handleLlm(json);
    break;
}

这种设计的好处是非常直接:

  • 语义事件和音频流分离,便于调试
  • 文本协议结构清晰,扩展成本低
  • 音频帧不做多余 JSON 包装,减少带宽和序列化成本

除了常规的 hello / listen / stt / tts / llm,协议里还定义了一个非常关键的控制消息:abort

json 复制代码
{
  "type": "abort"
}

它的语义很直接:立即终止当前播报或当前轮对话处理流程 。在语音助手场景里,abort 基本就是"打断"的标准控制指令。


连接状态:从 Idle 到 Ready,再进入 Listening / Speaking

语音交互页面最核心的一件事,就是必须把"现在到底处在哪个阶段"表达清楚。这个项目把状态拆成了两类:

  • 连接状态 VoiceConnectionState
  • 流水线状态 VoicePipelineState

定义如下:

dart 复制代码
enum VoiceConnectionState { idle, connecting, handshaking, ready, error, closed }

enum VoicePipelineState { idle, listening, speaking, aborting }

这种拆分非常合理。因为"连接已经建立"不代表"当前正在录音",同样"正在播放 TTS"也不代表连接状态发生变化。

1. 建链阶段

当 UI 调用 connect() 时,控制器会先做三件事:

  1. 规范化 URL
  2. 调用 Gateway 建立 WebSocket
  3. 发送 hello 并启动握手超时计时器

核心逻辑如下:

dart 复制代码
await gateway.connect(url: normalizedUrl, headers: headers);
_setState(connection: VoiceConnectionState.connecting);
_sendHello();
_handshakeTimer = Timer(handshakeTimeout, () {
  _setState(
    connection: VoiceConnectionState.error,
    pipeline: VoicePipelineState.idle,
    error: 'handshake timeout ${handshakeTimeout.inSeconds}s',
  );
});

这里有一个工程细节值得点赞:连接成功不等于可用,必须等服务端 hello 回来后才进入 ready。这可以有效避免"Socket 已连上,但音频参数还没协商好"时就开始上传音频。

2. 握手完成阶段

收到服务端 hello 后,控制器会:

  • 解析 session_id
  • 读取服务端 audio_params
  • 调用 audioEngine.reconfigure(...)
  • 切到 ready
dart 复制代码
await audioEngine.reconfigure(parsed.audioParams);
_setState(
  connection: VoiceConnectionState.ready,
  sessionId: parsed.sessionId,
  pipeline: VoicePipelineState.idle,
  error: '',
);

这一步尤其重要,因为播放链路和编码链路都必须和服务端协商参数对齐。否则最容易出现的问题就是:

  • 播放速度不对
  • 音色发闷
  • 帧长不匹配
  • 解码失败或卡顿

3. 自动重连与自动开听

UI 层在 VoiceWsDemoScreen 里还做了自动重连和自动监听:

dart 复制代码
_reconnectTimer ??= Timer.periodic(const Duration(seconds: 3), (timer) {
  if (conn == VoiceConnectionState.ready ||
      conn == VoiceConnectionState.handshaking ||
      conn == VoiceConnectionState.connecting) {
    return;
  }
  _tryConnectOnce(reason: 'auto-tick');
});

当状态切到 ready 后,还会自动触发一次 startListen(mode: 'auto')。这让页面更像一个真正的"在线语音助手",而不是只会被动等待按钮点击的调试页面。

另外,从协议文档看,listen.mode 还支持 realtime。它的语义是"更实时,并允许触发时打断当前播报"。虽然当前 Demo 默认走的是 auto,但协议本身已经为更强的实时打断场景预留了能力。

如果想让读者更直观理解一次完整会话里的 WS 消息往返,可以把消息时序图直接放在这里:
后端 WebSocket 前端 后端 WebSocket 前端 hello(audio_params) hello hello(session_id, audio_params) hello listen.start(mode=auto) binary opus listen.stop stt(text) stt llm(text, emotion=thinking) llm tts.start tts.start binary opus chunk binary tts.stop tts.stop


表情流转:连接、倾听、思考、播报如何映射到 UI

除了文字日志,这个页面还有一个很有意思的设计:把语音交互状态映射成表情

EmotionState 定义了几个核心状态:

dart 复制代码
enum EmotionState {
  idle,
  connecting,
  ready,
  listening,
  thinking,
  speaking,
  error,
  closed,
}

EmotionAvatar 会把这些状态映射成直观的 emoji:

dart 复制代码
EmotionState.connecting => '🔌'
EmotionState.ready => '🎙️'
EmotionState.listening => '🎙️'
EmotionState.thinking => '🤔'
EmotionState.speaking => '🗣️'
EmotionState.error => '⚠️'
EmotionState.closed => '😴'

1. 基础状态来自连接快照

页面首先根据连接状态和流水线状态推导一个"基础表情":

dart 复制代码
EmotionState _deriveBaseEmotion(VoiceSessionSnapshot snap) {
  if (snap.connectionState == VoiceConnectionState.error) {
    return EmotionState.error;
  }
  if (snap.connectionState == VoiceConnectionState.connecting ||
      snap.connectionState == VoiceConnectionState.handshaking) {
    return EmotionState.connecting;
  }
  if (snap.connectionState == VoiceConnectionState.closed) {
    return EmotionState.closed;
  }
  if (snap.connectionState == VoiceConnectionState.ready) {
    switch (snap.pipelineState) {
      case VoicePipelineState.speaking:
        return EmotionState.speaking;
      case VoicePipelineState.listening:
        return EmotionState.listening;
      case VoicePipelineState.idle:
      case VoicePipelineState.aborting:
        return EmotionState.ready;
    }
  }
  return EmotionState.idle;
}

也就是说:

  • connecting/handshaking -> 🔌
  • ready + idle -> 🎙️
  • ready + listening -> 🎙️
  • ready + speaking -> 🗣️
  • error -> ⚠️

2. LLM 和 STT 会短暂推动"思考态"

除了基础状态,页面还会根据 sttllm 消息给用户一个"思考中"的提示。

dart 复制代码
void _applyThinkingHint({String? emotion, bool fromStt = false}) {
  final normalized = emotion?.toLowerCase();
  final pipelineIdle = _snapshot.pipelineState == VoicePipelineState.idle;
  if (normalized == 'thinking' && pipelineIdle) {
    _setEmotion(EmotionState.thinking, hold: const Duration(milliseconds: 500));
    return;
  }
  if (fromStt && pipelineIdle) {
    _setEmotion(EmotionState.thinking, hold: const Duration(milliseconds: 150));
  }
}

这意味着:

  • 收到 stt 后,页面会短暂进入 🤔
  • 收到 llm.emotion == thinking 时,也会进入 🤔

这是一个很细腻的体验设计。因为在用户说完话、TTS 还没开始前,如果 UI 一直停留在"监听态",用户会误以为系统没有反应。短暂切成 thinking,能更自然地表达"我在处理了"。

3. TTS 会提升为 Speaking 态

当收到 tts.starttts.sentence_start 时,表情会切到 speaking

dart 复制代码
void _applyTtsEmotion(TtsState state) {
  switch (state) {
    case TtsState.start:
    case TtsState.sentenceStart:
    case TtsState.sentenceEnd:
      _setEmotion(
        EmotionState.speaking,
        hold: const Duration(milliseconds: 400),
      );
      break;
    case TtsState.stop:
      _setEmotion(
        _deriveBaseEmotion(_snapshot),
        hold: const Duration(milliseconds: 300),
      );
      break;
  }
}

4. 表情切换不是简单覆盖,而是带优先级和保留时间

项目还专门给表情加了优先级,防止状态抖动:

dart 复制代码
int emotionPriority(EmotionState state) {
  switch (state) {
    case EmotionState.error:
      return 7;
    case EmotionState.connecting:
      return 6;
    case EmotionState.speaking:
      return 5;
    case EmotionState.listening:
      return 4;
    case EmotionState.thinking:
      return 3;
    ...
  }
}

并且在 _setEmotion 中加入了 hold 机制。如果当前高优先级表情还在保留时间内,就不会被低优先级状态立刻打掉。

这解决了一个实际问题:stt -> llm -> tts.start 这些事件有时来得非常快,如果没有 hold,UI 会出现肉眼可见的闪烁。

打断时的表情也遵循同样的思路。当前控制器会先把流水线状态切到 aborting,随后很快回到 idle。UI 并没有为 aborting 单独设计一个表情,而是复用基础状态回退到 ready,从而避免"打断中"状态只闪一下、反而让用户困惑。

如果文章面向前端同学,这里很适合直接补一张"状态与表情变化图":
idle 🙂
connecting 🔌
handshaking 🔌
ready 🎙️
listening 🎙️
thinking 🤔
speaking 🗣️
aborting -> ready 🎙️
error ⚠️
closed 😴


打断逻辑:从按钮点击到服务端停播

在语音交互里,"打断"是体验里非常关键的一环。用户不会等系统完整播完再说下一句,因此系统必须具备快速停播、快速复位、快速重新进入下一轮对话的能力。

这份代码中的打断逻辑主要分为两类:

  • 主动打断 :用户点击打断按钮,前端显式发送 abort
  • 协议级打断结束 :服务端返回 tts.stop,前端进入 drain 收尾

1. 前端主动打断:先停本地,再通知服务端

控制器中的 abort() 是整个打断链路的核心:

dart 复制代码
Future<void> abort() async {
  _setState(pipeline: VoicePipelineState.aborting);
  _cancelDrainTimer();
  _playbackActive = false;
  audioEngine.setPlaybackActive(false);
  audioEngine.setDrainMode(false);
  await audioEngine.stopPlaybackAndClear();
  await audioEngine.stopCapture();
  try {
    if (!gateway.isConnected) {
      _emitLog('SKIP abort: not connected');
    } else {
      await gateway.sendJson(<String, dynamic>{'type': 'abort'});
      _emitLog('SEND abort');
    }
  } catch (error) {
    _emitLog('abort skipped: $error');
  }
  _speaking = false;
  _setState(pipeline: VoicePipelineState.idle);
}

这段实现里最值得注意的点是执行顺序:

  1. 先把流水线状态置为 aborting
  2. 停止 drain 定时器,防止旧的尾音收尾逻辑继续运行
  3. 关闭 playbackActivedrainMode
  4. 先本地停播、清空播放
  5. 再发 abort 给服务端
  6. 最后把状态切回 idle

为什么本地要先停?因为从用户体验角度看,打断是即时动作。用户点下去的一瞬间,如果前端还要等网络往返再停止播放器,UI 和声音都会显得"慢半拍"。所以当前实现采用的是典型的"本地先执行,服务端后对齐"策略。

2. UI 层如何触发打断

页面层已经直接接入了控制器的 abort()

dart 复制代码
await _controller.abort();

这意味着打断在前端并不是"概念支持",而是已经有完整实现路径:

  • 页面按钮触发
  • 控制器执行状态切换
  • 音频引擎停止播放与采集
  • Gateway 发出 abort 文本消息

3. 为什么打断不只是停播

很多实现会把打断理解成"把播放器停掉就结束了",但这份代码的处理更完整。因为打断时除了播放队列,往往还同时存在:

  • 还没播完的下行音频
  • 仍在采集中的麦克风流
  • 可能尚未结束的 drain 计时器
  • speaking / playbackActive / pendingTtsStop 等状态位

如果只停播放器,不重置这些状态,下一轮对话很容易出现:

  • 旧音频残留
  • 新一轮 TTS 被旧队列污染
  • UI 状态和真实播放状态不一致

所以这里的 abort() 实际上做的是一次"语音流水线重置",而不只是简单停播。

4. 协议级结束:tts.stop 不是立刻停,而是进入 drain

与主动打断不同,服务端正常结束播报时,控制器不会粗暴立即 stopPlaybackAndClear(),而是走一段 drain 收尾逻辑:

dart 复制代码
case TtsState.stop:
  _enterDrain();
  _setState(pipeline: VoicePipelineState.idle);
  _events.add(
    VoiceSessionEvent(type: VoiceSessionEventType.ttsStop, data: tts),
  );
  _emitLog('RECV tts.stop (enter drain)');
  break;

_enterDrain() 内部会启动一个 50ms 的定时检查:

dart 复制代码
_drainTimer ??= Timer.periodic(
  const Duration(milliseconds: 50),
  (_) => _checkDrain(),
);

随后根据几个条件决定何时真正停播:

  • stopAgo >= minStopDelayMs
  • 距最后一帧下行音频足够久
  • 播放队列足够短或已空
  • gapAgo >= drainIdleGapMs
  • 或者超时达到 drainMaxWaitMs

这样做的好处是:正常结束播报时尽量不截断尾音,主动打断时又能快速停下。这也是"结束"和"打断"在实现层最大的区别。

5. 打断和下一轮监听如何衔接

打断完成后,控制器把流水线恢复到 idle。这为下一轮交互留出了非常干净的起点:

  • 可以重新发送 listen.start
  • 可以走 realtime 模式做更激进的边播边打断
  • UI 也会从 speaking 回退到 ready

从交互设计上看,这一点很重要。打断不是终点,而是为了更快进入下一轮对话。

这一段配一张打断时序图最合适,能把"本地先停、服务端后对齐"的设计讲清楚:
Player 后端 Controller 前端UI 用户 Player 后端 Controller 前端UI 用户 tts.start + binary audio 播放中 点击打断 abort() stopPlaybackAndClear() {"type":"abort"} tts.stop pipeline ->> idle


前端上行音频:采集、拼接、编码、发送

WebSocket 上行的不是 PCM,而是 Opus 二进制帧。这意味着前端必须先把麦克风数据处理成协议约定的帧格式。

1. 采集入口

DefaultVoiceAudioEngine 中,录音可以走两条路:

  • 原生采集 NativeAudioCapture
  • Flutter 通用采集 record

对于 record 分支,采到的是连续 PCM16 字节流:

dart 复制代码
final stream = await _recorder.startStream(
  RecordConfig(
    encoder: AudioEncoder.pcm16bits,
    sampleRate: _params.sampleRate,
    numChannels: _params.channels,
  ),
);

2. 为什么需要 FrameAssembler

录音流回来的数据块大小并不一定天然对齐 60ms 一帧,因此项目引入了 FrameAssembler 做拼接和切帧。

dart 复制代码
_assembler?.push(bytes);
while (_assembler?.hasFrame ?? false) {
  final frame = _assembler?.popFrame();
  final encoded = _processFrameFromInt16(frame);
  if (encoded != null) {
    _uplink.add(encoded);
  }
}

FrameAssembler 干的事情可以理解为:

  • 把零散的 PCM 字节流累积起来
  • sampleRate * channels * frameMs / 1000 切成固定帧
  • 处理 odd byte、跨 chunk 拼接、缓冲区溢出等问题

核心拼接逻辑如下:

dart 复制代码
final evenByteCount = remainingBytes - (remainingBytes % 2);
samplesToWrite += evenByteCount ~/ 2;

if (evenByteCount > 0) {
  final bd = ByteData.view(
    chunk.buffer,
    chunk.offsetInBytes + start,
    evenByteCount,
  );
  for (int i = 0; i < bd.lengthInBytes; i += 2) {
    _buffer.pushSample(bd.getInt16(i, Endian.little));
  }
}

这一层非常重要,因为只要帧长不稳,后面的 Opus 编码和服务端解码就都可能出问题。

3. PCM 转 Opus

切好帧之后,才会真正进入编码流程:

dart 复制代码
final encoded = _encoder!.encode(input: frameSamples);
return Uint8List.fromList(encoded);

编码前还会先做一层上传判定 _shouldUpload(...)。虽然这部分主要与 VAD / 回声门控有关,但从 WebSocket 交互角度看,它也带来了一个直接收益:避免无效静音帧和回声帧浪费带宽

4. 上行发送

audioEngine.uplinkStream 产出一帧 Opus 后,控制器直接通过 Gateway 发二进制:

dart 复制代码
_uplinkSeq += 1;
_emitLog('SEND bin len=${frame.length} seq=$_uplinkSeq');
await gateway.sendBinary(frame);

所以前端上行链路可以总结成:
麦克风PCM
FrameAssembler拼帧
上传门控
Opus编码
WebSocket Binary


前端下行音频:接收、缓存、格式转换、排队播放

相比上行,下行链路更容易踩坑,因为它既要保证"及时播",又要避免"过早播、播卡顿、播断尾"。

1. tts.start 之前的二进制先缓存

服务端并不一定保证文本事件和音频事件严格顺序一致。也就是说,前端有可能先收到二进制帧,再收到 tts.start。如果这时直接播,就会造成状态错乱。

项目的处理方式是:在 speaking 还没开始前,先把二进制帧放进 _preSpeakBin 队列里

dart 复制代码
final allowPlayback = _speaking || _pendingTtsStop;
if (!allowPlayback) {
  _preSpeakBin.add(bytes);
  if (_preSpeakBin.length > _preSpeakMaxFrames) {
    _preSpeakBin.removeFirst();
  }
  _emitLog('[AUDIO][DOWNLINK_BUF] stage=prespeak ...');
  return;
}

等收到 tts.start 之后,再统一 flush:

dart 复制代码
final flushed = _preSpeakBin.length;
while (_preSpeakBin.isNotEmpty) {
  final frame = _preSpeakBin.removeFirst();
  await audioEngine.enqueueRemoteAudio(frame);
}

这是一个非常实用的抗乱序设计。

2. Opus 解码成 PCM

真正进入播放引擎后,下行二进制会先被解码为 PCM:

dart 复制代码
final pcm = _decoder!.decode(input: data);
final pcmBytes = Uint8List(pcm.length * 2);
final view = ByteData.view(pcmBytes.buffer);
for (var i = 0; i < pcm.length; i++) {
  view.setInt16(i * 2, pcm[i], Endian.little);
}

这里发生了一个关键的"格式转换":

  • WebSocket 二进制帧:Opus
  • 播放器输入格式:PCM16 little-endian

也就是说,前端并不是直接播放服务端传来的 Opus,而是先解码成 PCM,再交给播放器

3. 排队与预缓冲

解码后的 PCM 不会立刻 feed 给播放器,而是先进入 _playQueue

dart 复制代码
_playQueue.add(_PcmChunk(bytes: pcmBytes, samples: pcm.length));
_queuedSamples += pcm.length;

随后根据预缓冲阈值决定是否开始排空队列:

dart 复制代码
final prebufferMs = VoiceWsEnv.playPrebufferMs;
if (_playDrainerRunning || !_playerReady) {
  _drainPlayQueue();
} else {
  final queuedMs = queuedMsEstimate;
  if (queuedMs >= prebufferMs) {
    _drainPlayQueue();
  }
}

这一步非常关键。因为音频网络流通常是"离散到达"的,如果一来一帧就立刻播放,很容易抖动、爆音或者断续。适度预缓冲之后,听感会平滑很多。

4. 最终播放

排队后的数据会通过 FlutterPcmPlayer.feed(...) 一帧帧喂给播放器:

dart 复制代码
while (_playQueue.isNotEmpty && _playerReady) {
  final item = _playQueue.removeFirst();
  await player.feed(item.bytes);
}

因此,下行处理链路可以概括为:
WS Binary Opus
preSpeak缓存
Opus解码
PCM16字节
播放队列
FlutterPcmPlayer.feed

如果希望把"上行采集、后端处理、下行播放"一口气讲全,也可以把总链路图放在这里:
下行:服务端 -> 用户
后端处理
上行:用户 -> 服务端
Mic PCM16
FrameAssembler 拼接/切帧
VAD / 上传门控
Opus 编码
WebSocket Binary 上传
ASR 识别
LLM 理解/生成
TTS 合成
Opus 二进制下发
preSpeak 缓存
Opus 解码为 PCM16
播放队列 / 预缓冲
FlutterPcmPlayer 播放

从格式上看,这里实际发生了两次关键转换:

  • 上行:PCM16 -> Opus binary
  • 下行:Opus binary -> PCM16

前后端职责分工:谁做 ASR,谁做 LLM,谁做 TTS

这里需要说明一点:当前仓库里前端代码非常完整,但后端并不在 Flutter 工程内。不过结合 前端交互协议文档.md、消息模型定义以及上下行链路,可以较明确地推断出后端的职责分工。

1. 前端负责什么

前端负责的是"音频采集 + 协议交互 + 状态展示 + 音频播放":

  • 发送 hellolisten
  • 采集麦克风 PCM
  • 转成 Opus 二进制帧并持续上传
  • 接收 stt / llm / tts
  • 接收下行 Opus 并解码播放
  • 驱动连接状态、表情状态和文案更新

2. 后端主要负责什么

从协议结构来看,后端主要承担的是四件核心工作:

  1. ASR

    • 接收前端持续上传的 Opus 二进制
    • 解码后送语音识别
    • 识别完成后返回 stt
  2. LLM 分析与回答生成

    • 基于 stt.text 做理解、检索、问答
    • 返回 llm.text
    • 可附带 emotion,例如 thinking
  3. TTS 合成

    • 把生成的答案转成播报文本
    • 通过 tts.start / sentence_start / sentence_end / stop 描述播报节奏
  4. 语音转 Opus 二进制

    • 把合成后的声音编码成与握手参数匹配的 Opus 帧
    • 通过 WebSocket Binary 按顺序下发给前端

换句话说,这是一条非常典型的后端语音流水线:
前端上传Opus
语音识别
大模型理解与生成
语音合成
编码为Opus二进制
前端接收播放


连接状态、表情流转与音频处理是怎么串起来的

这套实现最值得借鉴的一点,是它没有把"连接""UI""音频"写成互相孤立的逻辑,而是通过 VoiceSessionController 串成了一条完整链路:

  • Gateway 只负责 WS 收发,不碰业务状态
  • AudioEngine 只负责音频采集、编码、解码、播放
  • Controller 负责协议状态机和消息路由
  • UI 根据事件总线更新文案、日志、表情和覆盖层

这让整条链路非常清晰:

  1. VoiceWsGateway 把 Text/Binary 转成事件
  2. VoiceSessionController 解析事件并驱动状态变化
  3. VoiceAudioEngine 做音频格式转换和播放
  4. VoiceWsDemoScreen 根据 stateChanged/stt/llm/tts 更新 UI

其中,表情流转不是"单独写死的动画",而是绑定了语义阶段:

  • 连接中:🔌
  • 连接就绪:🎙️
  • 正在听:🎙️
  • 正在思考:🤔
  • 正在播报:🗣️
  • 出错:⚠️
  • 关闭:😴

这会让用户在没有看日志的情况下,也能大致理解系统当前在做什么。

如果把"打断"也放进去看,这条链路会更完整:
正在播报
用户点击打断
controller.abort
本地停播/停采集
发送 abort 给服务端
服务端停止当前回复
返回 tts.stop
前端回到 idle/ready


难点与解决方案

做这类 WebSocket 语音交互,工程上最容易踩的坑主要有下面几个,而代码里其实都给了对应方案。

1. 握手成功前不能盲目开录

如果 hello 还没协商完,就开始采集和上传,很可能音频参数对不齐。当前实现通过 ready 状态显式限制了 startListen,避免了这个问题。

2. 文本事件和音频帧可能乱序

服务端二进制音频可能比 tts.start 更早到,代码通过 _preSpeakBin 做了预缓存,这能显著提高鲁棒性。

3. 服务端下发采样率可能变化

客户端拿到服务端 hello.audio_params 后会 reconfigure 音频引擎,而不是死守本地默认参数。这一点非常重要,否则最容易出现"声音变慢、音色异常"。

4. 播放结束不能立刻清空

收到 tts.stop 并不代表播放器里已经没有数据了,所以项目引入了 drain 阶段,通过计时器轮询尾音和队列长度,保证播放收尾更自然。

5. UI 状态容易抖动

sttllmtts.start 来得很快,如果直接切表情,页面会闪。项目通过优先级和 hold 机制做了平滑处理。

6. 打断要"够快",但也要"够干净"

如果只发送 abort 而不先停本地播放器,用户会感觉打断有延迟;如果只停本地播放器而不通知服务端,后端仍可能继续推流。所以当前实现同时处理了本地状态和服务端状态,这正是打断体验稳定的关键。


总结

这套 WebSocket 语音交互实现,真正有价值的地方不只是"能通",而是把前端语音链路里几个最关键的问题都落地了:

  • hello 完成音频参数协商
  • 用连接状态和流水线状态驱动 UI
  • 用表情状态把"连接、倾听、思考、播报"可视化
  • FrameAssembler + Opus 编解码 + PCM 播放队列 串起音频处理闭环
  • abort + tts.stop + drain 把打断体验和正常收尾同时处理好
  • stt / llm / tts / binary 完成一轮完整的语音对话

如果要用一句话总结这套设计,我会这样说:

它不是单纯地把 WebSocket 接上了,而是把"协议、状态、表情、音频处理、播放体验"整合成了一条完整、可交互、可观测的语音链路。

对于一个"简单语音交互"的前端实现来说,这已经不是 demo 级别的打通,而是一套具备工程化思维的完整方案。

相关推荐
TE-茶叶蛋2 小时前
从零实现H5 表格协同编辑:Yjs + WebSocket 实战
websocket·小程序·excel
大Mod_abfun2 小时前
AntdUI教程#1ChatList交互(vb.net)
服务器·前端·ui·交互·antdui·聊天框
王家视频教程图书馆2 小时前
测试开源视频播放器在RN webview中的运行方式
开源·音视频
奔跑吧 android2 小时前
【车载audio】【AudioService 01】【Android 音频子系统分析:按键音(Sound Effects)开启与关闭机制深度解析】
android·音视频·audioflinger·audioservice·audiohal
捧 花2 小时前
Go + Gin 实现 HTTPS 与 WebSocket 实时通信
websocket·golang·https·go·gin
带娃的IT创业者2 小时前
UI 交互难题攻克:遮挡、弹窗、动态加载
ui·交互·文件上传·浏览器自动化·playwright·ui 交互·元素遮挡
MIXLLRED2 小时前
Ubuntu 22.04 + ROS2 Humble 环境下设计图形化交互界面
linux·ubuntu·交互·图形界面
witAI2 小时前
**即梦仿真人剧2025推荐,沉浸式情感交互新体验**据
python·交互
stolentime3 小时前
洛谷P15652 [省选联考 2026] 排列游戏 / perm题解
c++·算法·交互·洛谷·联合省选2026