引言
做一个"能说、能听、还能实时反馈状态"的语音交互页面,看起来像是把 WebSocket 接上就行,但真正落到工程实现里,复杂度远不止"发文本、收文本"这么简单。
在这个项目里,WebSocket 承担了两类完全不同的数据:
- 文本控制消息 :如
hello、listen、stt、tts、llm - 二进制音频帧:上行 Opus 音频、下行 Opus 音频
与此同时,前端不仅要管理连接状态,还要在 UI 上实时表达"当前到底是正在连接、正在听、正在思考、还是正在说话"。这就是为什么代码里同时出现了:
VoiceSessionController:负责协议状态机与上下行路由VoiceAudioEngine:负责采集、编码、解码、播放WebSocketVoiceWsGateway:负责 WS 收发EmotionAvatar/EmotionState:负责表情和状态的可视化表达
本文就基于项目中的 voice_ws 相关代码,系统梳理这一套前后端"简单语音交互"是如何跑起来的,重点会覆盖:
- WebSocket 连接与握手状态管理
- 表情状态如何跟随连接、识别、播报过程变化
- 前端语音数据如何采集、拼接、编码、发送、解码、播放
- 从协议与消息结构可以推断出的后端职责:ASR、LLM、TTS 和 Opus 二进制下发
功能概述
这套语音交互链路,本质上是一条"控制消息 + 音频流"并行工作的通道。
从用户视角看,完整流程非常自然:
- 前端建立 WebSocket 连接
- 发送
hello协商音频参数 - 连接就绪后,开始
listen.start - 前端持续上传 Opus 音频帧
- 后端完成 ASR,返回
stt - 后端完成 LLM 推理,返回
llm - 后端开始 TTS,发送
tts.start - 后端持续下发 Opus 二进制音频
- 前端解码、排队、播放
- 播放结束后收到
tts.stop,页面回到待命状态
如果用户希望在播报过程中立即打断,还会多出一条分支:
- 用户点击打断按钮,或业务层判定需要中断当前播报
- 前端立即执行
abort - 本地播放器先停播并清理队列,再向服务端发送
{"type":"abort"} - 服务端停止当前 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 / HelloResponseListenMessageSttMessageTtsMessageLlmMessage
例如,前端发起握手时会发送:
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() 时,控制器会先做三件事:
- 规范化 URL
- 调用 Gateway 建立 WebSocket
- 发送
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 会短暂推动"思考态"
除了基础状态,页面还会根据 stt 和 llm 消息给用户一个"思考中"的提示。
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.start 或 tts.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);
}
这段实现里最值得注意的点是执行顺序:
- 先把流水线状态置为
aborting - 停止 drain 定时器,防止旧的尾音收尾逻辑继续运行
- 关闭
playbackActive和drainMode - 先本地停播、清空播放
- 再发
abort给服务端 - 最后把状态切回
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. 前端负责什么
前端负责的是"音频采集 + 协议交互 + 状态展示 + 音频播放":
- 发送
hello和listen - 采集麦克风 PCM
- 转成 Opus 二进制帧并持续上传
- 接收
stt/llm/tts - 接收下行 Opus 并解码播放
- 驱动连接状态、表情状态和文案更新
2. 后端主要负责什么
从协议结构来看,后端主要承担的是四件核心工作:
-
ASR
- 接收前端持续上传的 Opus 二进制
- 解码后送语音识别
- 识别完成后返回
stt
-
LLM 分析与回答生成
- 基于
stt.text做理解、检索、问答 - 返回
llm.text - 可附带
emotion,例如thinking
- 基于
-
TTS 合成
- 把生成的答案转成播报文本
- 通过
tts.start / sentence_start / sentence_end / stop描述播报节奏
-
语音转 Opus 二进制
- 把合成后的声音编码成与握手参数匹配的 Opus 帧
- 通过 WebSocket Binary 按顺序下发给前端
换句话说,这是一条非常典型的后端语音流水线:
前端上传Opus
语音识别
大模型理解与生成
语音合成
编码为Opus二进制
前端接收播放
连接状态、表情流转与音频处理是怎么串起来的
这套实现最值得借鉴的一点,是它没有把"连接""UI""音频"写成互相孤立的逻辑,而是通过 VoiceSessionController 串成了一条完整链路:
- Gateway 只负责 WS 收发,不碰业务状态
- AudioEngine 只负责音频采集、编码、解码、播放
- Controller 负责协议状态机和消息路由
- UI 根据事件总线更新文案、日志、表情和覆盖层
这让整条链路非常清晰:
VoiceWsGateway把 Text/Binary 转成事件VoiceSessionController解析事件并驱动状态变化VoiceAudioEngine做音频格式转换和播放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 状态容易抖动
stt、llm、tts.start 来得很快,如果直接切表情,页面会闪。项目通过优先级和 hold 机制做了平滑处理。
6. 打断要"够快",但也要"够干净"
如果只发送 abort 而不先停本地播放器,用户会感觉打断有延迟;如果只停本地播放器而不通知服务端,后端仍可能继续推流。所以当前实现同时处理了本地状态和服务端状态,这正是打断体验稳定的关键。
总结
这套 WebSocket 语音交互实现,真正有价值的地方不只是"能通",而是把前端语音链路里几个最关键的问题都落地了:
- 用
hello完成音频参数协商 - 用连接状态和流水线状态驱动 UI
- 用表情状态把"连接、倾听、思考、播报"可视化
- 用
FrameAssembler + Opus 编解码 + PCM 播放队列串起音频处理闭环 - 用
abort + tts.stop + drain把打断体验和正常收尾同时处理好 - 用
stt / llm / tts / binary完成一轮完整的语音对话
如果要用一句话总结这套设计,我会这样说:
它不是单纯地把 WebSocket 接上了,而是把"协议、状态、表情、音频处理、播放体验"整合成了一条完整、可交互、可观测的语音链路。
对于一个"简单语音交互"的前端实现来说,这已经不是 demo 级别的打通,而是一套具备工程化思维的完整方案。