Tg Bot 实时通话与主动来电方案
LAYERS04DEWDEW ATELIERBUILD RECORDPHONE CALLATELIERBUILD RECORD04 components in this object

从选型思考到真实实现:在既有 Telegram Bot 上加入实时语音,讨论 WebSocket 与 WebRTC、VAD 与端点检测、音频感知、完整 TTS、共享主会话、打断与恢复,以及 Bot 主动来电的控制协议设计和一轮轮真机通话里修出来的细节。

这个功能最初看起来很像一个常见需求:给已有的 Telegram Bot 加上实时语音通话。但真正开始设计后,我发现“实时语音”至少有两种完全不同的做法。

第一种是原生全双工 Speech-to-Speech。麦克风音频持续送给模型,模型边听、边想、边生成音频,整体更接近 GPT-Live 一类方案。

第二种是保留传统对话轮次,只把录音和播放变成自动化:用户不再手动按住录音,系统自动判断开始说话和说完;AI 回复时仍然可以被打断,也可以恢复播放。

我最后选择了第二种:

Turn-based realtime voice + full-duplex local audio I/O

也就是:交互上像电话,认知层仍然是一轮一轮的。

User Turn
  → 完整音频
  → 一次性 STT
  → 共享的对话大脑
  → 完整 Assistant Message
  → 一次性 TTS
  → 自动播放

这里的“全双工”只发生在本地音频层:AI 播放语音时,麦克风仍然保持监听,用户可以随时开口。服务端并没有变成一条持续运行的流式语音推理管线。

这个取舍牺牲了一部分首声延迟,但换来了几个对陪伴型 AI 更重要的东西:稳定的轮次边界、完整上下文、连续的情绪表达,以及容易理解和恢复的状态。

设计的四条原则

1. 语音通话不是新 conversation

实时通话和普通文字聊天必须共用同一份 Telegram 主会话历史。文字聊到一半,可以直接切到语音;挂断以后,也可以继续打字。通话里的用户发言、AI 回复、工具调用和记忆使用仍然属于原来的 conversation。call_id 只负责音频文件、回放、统计和断线恢复,不能被理解成新的聊天房间。

这条原则也适用于 Bot 主动来电:由 Bot 发起通话,并不意味着另造一个“会打电话的 Bot”。发起意图、接听反馈和接通后的内容仍然属于同一个主 Agent;只需要为尚未接通的邀请增加独立的生命周期。

2. 不用 partial transcript 驱动 LLM

一句话没有结束之前,STT 的中间结果很容易改写。如果把“我觉得”“我觉得这个”“我觉得这个方案”依次提交给 LLM,不但会产生重复回复,还会污染主会话历史。所以每次用户讲话只提交一条 User Message:

VAD 确认结束
  → 完整音频
  → 一次性 STT
  → 一条完整 User Message

3. TTS 必须看到完整回复

这个项目的声音依赖上下文、标点和 audio tags,例如:

[softly]
[laughs]
[sighs]
[whispers]

曾经试了一下把长回复拆成几组并行合成。首声确实更快了,但不同分组之间的情绪、语速和呼吸明显断裂,角色感反而变弱(其实是人感)。

最后还是撤掉了并行分段 TTS,恢复成完整 Assistant Turn 一次合成。模型文本可以增量出现在 UI 上,但只有完整回复确认以后,才会进入语音合成。

4. Thinking 应该可见且绝不能被朗读

模型的 thinking、工具事件和最终回答是三类不同数据。主会话能够提供、且允许展示的 thinking 或过程摘要会被保存为独立事件,在 Replay 中默认折叠;不能假设任何模型都提供完整原始内部推理。真正进入 TTS 的只有经过 Speech Post Processor 处理后的 reply_final。这样既保留可用的过程记录,也不会出现“AI 把自己的内部思考念出来”的问题。

为什么没有选择“更实时”的方案

这个方案并不是因为做不了流式,而是因为目标不同。

如果目标是语音 Benchmark,最自然的优化方向是流式 ASR、semantic endpointing、speculative generation、token-level TTS 和网络 PCM streaming。每一项都能缩短一部分延迟,但组合起来会形成一套很难调试的分布式状态机。

几个候选方案放在一起比较:

方案 首声延迟 情绪连续性 打断后的状态一致性 工程复杂度
原生 S2S 最低 取决于模型 高度依赖供应商协议 高
partial ASR + token TTS 较低 容易碎 很容易出现旧片段污染 很高
句子级并行 TTS 中等 分组之间明显断裂 需要管理多段播放队列 中高
完整 Turn + 完整 TTS 较高 最完整 最容易取消与恢复 中等

最终选择最后一种,是因为这个项目首先是角色型陪伴产品,其次才是低延迟实验。慢几百毫秒可以继续优化,但一次错误的情绪跳变、重复回复或错乱播放,会直接破坏用户对角色的信任。

还有一个容易被忽略的事实:用户对“像电话”的判断,并不只来自首声延迟。自动收音、自然停顿、随时插话、不中断上下文、稳定播放和挂断后的连续聊天,往往比单点延迟更影响整体体感。

为什么选择 WebSocket,而不是 WebRTC

“实时音频就该用 WebRTC”是一种很自然的直觉,但它不是无条件成立。

WebRTC 解决的是一整套实时媒体问题:音频轨道、Opus 编码、抖动缓冲、丢包恢复、拥塞控制、NAT 穿透,以及必要时的 STUN/TURN。多人通话、远程双向连续音频、对网络抖动很敏感的场景,直接使用 WebRTC 通常比自己在 WebSocket 上发 PCM 更合理。

我的场景少了几个关键条件:

  • 只有一个用户和一个 Bot,没有多人媒体路由;
  • 用户音频虽然分帧上传,但服务端要等完整 Turn 才调用 STT;
  • Assistant 音频由 TTS 完整生成并落盘,前端拿到资产 URL 后播放;
  • 真正需要持续双向交换的是控制事件,而不是两路实时媒体轨道;
  • Telegram WebView 已经需要处理鉴权、重连、Turn 状态和事件重放。

所以第一版选择了 WebSocket:JSON 传控制事件,binary frame 传当前 User Turn 的 16 kHz 单声道 PCM。

WebSocket
  ├─ open / resume / close
  ├─ start / end / interrupt
  ├─ binary PCM frames
  ├─ audio_ack
  └─ stt_final / thinking / reply / audio / error

这个选择的价值不只是“实现简单”。WebSocket 基于 TCP,在一条活连接内天然有序,正适合 turn_id、generation_id 和状态事件。断线后则由应用层补齐语义:客户端保留未确认的音频包,服务端返回 audio_ack;事件本身还有递增 seq,重连时只重放缺失部分。

这里要区分两件事:WebSocket 的 TCP 不会在同一连接里随便乱序,但连接断掉以后,它并不知道哪些业务动作已经提交。audio_ack 和事件 seq 解决的是跨连接恢复,不是在重新发明 TCP。

WebSocket 的代价

它没有免费的实时媒体能力。TCP 队头阻塞、PCM 体积、浏览器后台策略和移动网络切换都要自己面对;AEC、降噪和自动增益也主要依赖 getUserMedia:

{
  echoCancellation: true,
  noiseSuppression: true,
  autoGainControl: true
}

因此我不会把“用了 WebSocket”总结成通用最佳实践。它只是在当前约束下更匹配。

如果以后改成下面任一种形态,我会重新评估 WebRTC 的可能性:

  • 连续上行音频进入真正的流式 ASR 或原生 S2S;
  • Assistant 端也要低延迟持续下发音频帧;
  • 支持多人或跨设备通话;
  • 网络抖动已经成为主要质量瓶颈;
  • 需要更完整的实时媒体拥塞控制、带宽自适应和媒体统计。

换句话说,WebRTC 是媒体系统;当前实现更像一条带少量音频帧的、可恢复的会话协议。两者并不只是“谁延迟更低”的选择。

最终架构

整条链路可以分成四层:

Telegram Mini App / WebView
  ├─ 麦克风、AEC、降噪、自动增益
  ├─ Silero VAD
  ├─ Ring Buffer / Pre-roll
  ├─ Live transcript UI
  └─ Audio playback / barge-in
            │
            │ WebSocket:音频帧 + 状态事件
            ▼
Call Stream Service
  ├─ CallSession
  ├─ turn / generation 管理
  ├─ 音频缓冲与 ACK
  ├─ 断线事件重放
  ├─ silence watchdog
  └─ cancellation
            │
            ▼
Call Runtime
  ├─ Qwen3-ASR-Flash 完整 Turn 转写
  ├─ 声学特征 / 个人基线 / Qwen Plus 情绪推断
  ├─ 可选 YAMNet / 按条件触发的 Omni Captioner
  ├─ Conversation Bridge
  ├─ Speech Post Processor
  ├─ ElevenLabs v3 TTS proxy
  ├─ 通话记录与音频持久化
  └─ 翻译缓存
            │
            ▼
cc-connect / Telegram 主会话
  ├─ 完整聊天历史
  ├─ system prompt
  ├─ memory
  ├─ tools
  └─ LLM

前端负责“像电话”,后端负责“仍然是一轮可靠的聊天”。

主动来电则在这条链路外增加一个较薄的入口:主 Agent → tg-call 解析器 → Telegram 来电邀请 → Mini App 接听 → 原 CallSession。只有成功接通以后才进入下方同一套音频运行时;邀请状态不应该混进 PCM 或 STT 的轮次状态机。

代码里的实际职责边界

最终实现没有为了形式把每个类都拆成一个文件,但职责边界是明确的:

模块 主要职责
app.js call runtime 麦克风、VAD、pre-roll、播放器、字幕、重连与 Replay 交互
call_stream.py WebSocket 协议、CallSession、音频 ACK、识别/生成任务、silence watchdog
call_runtime.py STT/TTS 适配、听觉感知链路对接、Speech Post Processor、通话持久化、翻译与结束通知
window_bridge.py 将完整 Turn 送入 cc-connect 主会话,接收 thinking/reply 事件并处理超时
server.py 通话历史、音频 Range、翻译任务等 HTTP API

逻辑上还可以继续拆成 VadRuntime、PlaybackRuntime、InterruptionController 等对象,但当前阶段更重要的是先确保事件所有权清楚。例如只有 Call Stream 能推进 generation,只有 Call Runtime 能解析通话资产路径,只有 Bridge 能操作主 conversation。

听觉感知链路也遵守同样的边界:ASR 转写决定这轮准备提交的文本是什么;声学与环境分析输出带来源的参考观察;Captioner 不能替代 ASR,也不能私自创建一条主会话用户消息。

状态机:不要让一堆布尔值决定通话

实时通话最容易失控的地方,是麦克风、识别、生成和播放同时存在。即便代码里为了性能保留了一些布尔字段,产品逻辑仍然必须能归约到明确状态。

IDLE
  ↓
CONNECTING
  ↓
LISTENING
  ↓ candidate
POSSIBLE_SPEECH
  ↓ confirmed
RECORDING
  ↓ speech end
ENDPOINT_CONFIRMING
  ↓
TRANSCRIBING
  ↓
THINKING
  ↓
SYNTHESIZING
  ↓
SPEAKING
  ↓
LISTENING

打断会形成另一条支线:

SPEAKING
  ↓ VAD candidate
INTERRUPTION_CANDIDATE
  ├─ misfire    → SPEAKING
  └─ confirmed  → RECORDING

早期实现的问题,是把 candidate 直接做成了用户可见状态,还顺手暂停了音频。后来虽然界面上仍然可以表达“听见你了”,但内部只有 confirmed speech 才能推动真正的状态转移。

状态机还必须定义失败终态。STT、LLM、TTS、音频下载和播放器任一步失败,都要落到 FAILED 或 CANCELLED,释放当前资源并回到可继续监听的状态,不能永远卡在“让我想想”或“声音马上来”。

还有一个需要明确的抽象边界:上图是产品轮次的主状态,并不意味着输入、生成和输出三个子系统真的在串行运行。麦克风在 THINKING 和 SPEAKING 时仍然工作;播放器停止、LLM 取消和新用户讲话也可能同时发生。更准确的实现思路是让输入侧(VAD/录音)、生成侧(STT/LLM/TTS)和输出侧(媒体播放)各自维护局部状态,再由 CallSession 协调所有权。这比让一个全局 isListening/isThinking/isPlaying 组合决定一切可靠。

从用户开口到提交完整 Turn

本地持续监听,而不是持续识别

通话开始后,浏览器通过 getUserMedia 打开麦克风,同时开启:

{
  echoCancellation: true,
  noiseSuppression: true,
  autoGainControl: true
}

麦克风数据会持续进入本地 VAD,但不会持续调用 STT。只有检测到真实讲话后,才会创建新的用户轮次。

浏览器同时维护大约 800ms 的 pre-roll。VAD 的确认永远存在延迟,如果从“确认讲话”那一刻才开始录制,句首的一两个字很容易被截掉。Pre-roll 会把确认前的音频一起带入当前 Turn。

VAD 是两阶段确认,不是音量开关

最初的实现把 onSpeechStart 当成了真正讲话。一点环境声、咳嗽,甚至扬声器回声,都可能让页面显示“被打断”,并把 AI 音量降下来。

真实日志证明,这种做法误报太多。后来我把它拆成两个阶段:

candidate
  只记录,不改 UI,不暂停播放

confirmed speech
  创建新 Turn,停止 AI 播放,开始收音

当前 VAD 会根据场景使用不同参数。播放期间阈值最高,思考期间更谨慎,普通监听时相对灵敏。正常停顿要持续约 2 秒才结束本轮;硬上限从早期的 60 秒放宽到 180 秒,避免长发言被机械截断。

当前参数不是一套全局阈值,而是三个 profile:

场景 speech start speech continue 最短确认时间
普通监听 0.56 0.38 300ms
AI 思考中 0.64 0.46 420ms
AI 播放中 0.72 0.42 300ms

播放中的 start threshold 更高,是为了抵抗扬声器漏音;continue threshold 相对低一些,是为了确认用户开口后不要因为辅音、气声或短暂停顿立刻掉回静音。

Silero VAD 加载失败时,前端还能降级到自适应 RMS 检测。降级链路不追求与 Silero 完全等价,但至少保证通话不会因为一个 WASM 或模型资源加载失败而彻底不可用。

这件事给我的教训是:

VAD candidate 只能是一条观测信号,不能直接成为产品状态。

为什么选 Silero,而不是只用 RMS 或 WebRTC VAD

RMS 测量能量,无法判断这段声音是不是讲话;WebRTC VAD 是轻量的传统语音活动检测器;Silero 则通过神经网络给出语音概率,便于我为普通监听、模型思考和语音播放配置不同的确认门槛。这里没有必要宣称 Silero 在所有环境里都更好:它引入模型资产、运行时与帧处理成本,实际收益需要靠误触发和漏检日志验证。

更重要的是:VAD 不等于 Endpointing。前者回答“这一帧像不像讲话”,后者决定“用户是不是已经完成整轮表达”。“我觉得……其实还有个问题”中间可能有很长的停顿;静音不是语义结束的同义词。现在用约 2 秒 endpoint grace 和 recognition group 保留自然停顿,而没有为了缩短这段等待就把 partial ASR 直接接到主 LLM。未来可以研究 semantic endpointing,但需要额外处理识别回改、误判及误提交成本。

这些数字是当前版本经真实通话调出来的工程参数,不是通用推荐值;不同设备、回声条件和说话习惯,都可能需要重新评估。

音频可以分帧传输,但 STT 仍然一次完成

确认讲话后,浏览器把 16kHz 单声道 PCM 分帧通过 WebSocket 传给服务端。服务端按序号 ACK 并缓存在当前 turn_id 下。

这并不等于流式 ASR。直到前端发送 Turn End,服务端才会把 PCM 封装成完整 WAV,一次性交给已有的阿里 STT 链路。

本轮权威转写目前由 Qwen3-ASR-Flash 提供。它的接口具有原生情绪标注能力,但本系统附加的情绪 hint、个人声学特征和环境描述,并不是同一个 ASR 响应自动生成的一组字段:它们来自独立的声学分析、文本模型或可选音频理解支路。转写会作为 User Message 提交,附加观察则只能以来源分离的参考信息进入上下文。后面专门展开这套听觉感知栈。

WebSocket 协议不仅传音频

音频之外,WebSocket 还承担 Turn 控制、恢复和遥测。主要命令可以概括为:

客户端命令 作用
open 创建或接入一个 CallSession
start 开始新的用户 Turn,重置当前音频缓冲
binary audio 携带递增序号的 PCM 帧
end 结束录音,允许一次性 STT
interrupt 取消旧 generation,并记录播放位置
playback_state 告诉服务端 AI 是否仍在播放
resume 带上 last_seq 和 turn_id 恢复连接
close 幂等结束通话并释放资源

每个 PCM 包都有序号。客户端会短期保留尚未确认的音频包,服务端返回 audio_ack。网络重连后,客户端只补发最后 ACK 之后的帧,而不是把整个 Turn 从头上传。

服务端对每个 CallSession 维护一段带 seq 的事件缓存。客户端在 resume 时提交最后收到的事件序号,缺失的 STT、thinking、audio、reply_done 等事件会被标记为 replay 后补发。

这套协议解决的是弱网下的“至少不丢”,但还需要幂等约束解决“不要重复”。因此 turn_id、音频包序号、事件 seq 和 generation_id 分别负责不同层面的去重,不能拿一个 ID 包办全部问题。

用户补充讲话时,先不要急着回复

真实说话并不总是一次连续录音。用户可能在系统刚进入识别时又补上一小句。直接把它当新问题,会产生两个互相抢占的回复。

当前服务端允许识别阶段把连续录音加入同一个 recognition group。各段分别完成 STT 后,再用一小段自然静音拼接音频记录,并合并成一条最终 transcript。被取消的识别请求可以继续完成并保存结果,但不再获得启动 LLM 的权限。

这里的关键不是“所有请求都能物理取消”,而是把取消语义定义清楚:

一个已经发给 STT 供应商的请求可能无法立即中止,但它返回后只能被记录,不能再推动当前会话前进。

用一轮真实时序把它串起来

假设用户说:“I had a difficult day today.”,一轮正常请求会经历:

t0        VAD candidate,只记录
t0+300ms  confirmed,创建 turn 8,发送 pre-roll
t0+...    PCM 帧持续发送,服务端逐包 ACK
说完+2s   endpoint 确认,客户端发送 end
          服务端封装完整 WAV
          Qwen3-ASR-Flash 返回本轮转写
          声学特征 / 情绪推断 / 可选音频事件分析
          写入 input.wav 与 transcript.json(区分来源)
          通过 Bridge 提交一条主会话 User Message
          thinking/reply_delta 更新字幕
          reply_final 成为权威 Assistant Message
          Speech Post Processor 生成 displayText/ttsText
          完整回复进入一次 TTS
          assistant.bin 与 alignment 稳定落盘
          前端收到 audio,开始播放并上报 playback_state
          reply_done 写入该轮耗时与最终结果

任何一步发现 generation 已改变,就停止继续向下传播。这个规则比具体使用哪家 STT、LLM 或 TTS 更重要,因为供应商可以替换,异步结果污染的问题始终存在。

STT 之外:从识别文字到理解声音

通话里真正使用的是一套听觉感知栈,而不是一个万能 STT 模型。把这些能力混在一起,会产生两个危险误解:一是以为情绪标签是 ASR 的确定结论,二是把背景里的歌声或电视对白当成用户说的话。

当前链路分成四层。

1. 权威转写:Qwen3-ASR-Flash

每个完整 User Turn 会先转成 16 kHz mono WAV,一次性提交给阿里百炼的 Qwen3-ASR-Flash,并开启 ITN,把口语数字等内容归一化。它返回的文本是本轮唯一的 authoritative transcript。

生产通话的权威链路是 Qwen。代码里仍保留 Groq Whisper 和本地 Vosk 作为 ASR 兼容后备,但它们不是当前主路径,也不负责情绪判断。这里还刻意没有启用 partial transcript:模型可以在整段音频上获得完整上下文,主 conversation 只会收到一次提交。

少于约 0.5 秒的音频会先被拦截。极短或接近空白的录音不只“识别不准”,还可能诱发 ASR 幻觉;这种输入不值得进入主会话。

2. 情绪识别:文本、声学特征和个人基线的融合

Qwen3-ASR-Flash 本身就支持情绪识别:根据百炼的非实时 ASR 接口文档,其非流式 OpenAI 兼容响应可在 choices[].message.annotations[].emotion 返回七类标签:surprised、neutral、happy、sad、disgusted、angry、fearful。因此,额外做情绪分析,并不是因为 ASR 完全没有情绪能力。

我真正想补充的是个体差异。通用情绪标签回答“这段声音更像哪一种常见情绪”,但 Companion 还需要谨慎地观察“这次说话和这个人平时相比有什么变化”。为此,另一条后处理链路会从 WAV 中用 librosa 提取一组可解释的声学特征:

pitch / pitch range / pitch jitter
energy / energy sway
pause ratio
tempo / tempo sway
spectral brightness

前 8 次合格录音用于建立个人基线,之后保留最近约 200 个样本作为滚动窗口。系统用中位数和 MAD 衡量“这一次相对她平时偏了多少”。中位数比均值更不容易被地铁、户外或偶发噪声带偏。

随后,Qwen 文本模型会同时看到:

  • 用户实际说了什么;
  • 本轮绝对声学特征;
  • 相对个人基线的偏移;
  • 一组受约束的陪伴场景标签。

情绪判断由百炼侧的 Qwen Plus 完成,与主 ASR 共用同一供应链。它只允许从“开心、兴奋、撒娇、平静、累、低落、委屈、生气、嘴硬、紧张”等标签中选择,并生成一句克制的 hint。规则明确禁止编造原因或事件。

这是一条更细粒度、面向陪伴场景的参考推断,不是 Qwen3-ASR 原生七类情绪输出的同义替换。两者可以保留为不同来源的信号;原生标签可以作为对照,但现阶段不能仅凭“加入了个人基线”就宣称后者更准确。

这仍然不是测谎仪。confidence 是模型对本次判断的自评,没有经过统计校准;个人基线在冷启动、换麦克风、感冒或环境变化后也会漂移。前 8 次合格录音只是工程上的初始参考,不代表它已经统计稳定。情绪信息因此只作为参考观察注入,不能覆盖原始转写,更不能让模型把“低落”当成客观事实。

3. 背景音:可选的 YAMNet/AudioSet,而不是让 ASR 猜

环境声层实现为本地 ONNX 版 YAMNet。它根据 AudioSet 的 521 个类别输出音频事件分数,包括雨、雷、猫叫、键盘、门铃、车流、电视、音乐和乐器。这条链路和 ASR 分开:模型资产存在时才启用,缺失或推理失败就返回空列表,不能拖垮主转写。

为了不让用户人声盖住背景,系统会优先聚合 speech score 较低的帧,也就是讲话间隙。结果经过白名单、阈值和 Top-K 过滤,再映射成适合上下文使用的中文标签。

环境声与 transcript 分开保存:

transcript: “我今天有点累。”
environment: [雨声, 键盘声]

这样 Bot 可以知道说话发生在什么环境里,却不会把“键盘声”拼进用户原话。背景音乐也不会自动被解释成用户情绪;在媒体模式中,提示词还会明确禁止把配乐当成角色本人的感受。

4. 歌声与非语言声音:先检测事件,再按需描述

“歌声识别”至少有三种完全不同的需求:

  1. 检测现在是否在唱歌、哼唱或播放音乐;
  2. 描述声音里还有笑、叹气、哭、呼吸或乐器;
  3. 识别歌词、歌曲名、旋律或演唱者。

从架构上看,YAMNet 和 Captioner 可以为前两层提供线索,第三层则需要更专门的系统。YAMNet 能给出 Singing、Humming、Whistling、Music、Guitar、Piano 等事件。对于较长、背景复杂、停顿异常或包含明显非语言声的音频,系统还会低频调用 Qwen3.8-Omni-Flash Captioner,让它只描述可直接听见的 pace、pause、breath、laughter、crying、music、ambience 和 sound effect。

Captioner 的输出不会替代 ASR,也不会默认每轮调用。当前主要在音频超过约 8 秒、环境标签较多,或出现异常停顿、低音量等复杂信号时触发;在 YAMNet 降级时,当然也失去了“环境标签较多”这条触发信号。短而简单的讲话没有必要多花一次多模态推理;Captioner 失败也不能让主转写失败。它更像第二证据源,用于补齐“说了什么”之外的听觉信息。

至于歌词,ASR 有时能把清晰的唱词转成文字,但唱腔、伴奏、混响和拉长音都会改变错误分布。检测到 Singing 不等于可靠歌词转写,更不等于歌曲识别。若产品以后需要“听歌识曲”,应该接入音频指纹或音乐检索系统;需要高质量歌词,则要单独评测 singing voice ASR,而不是把环境声标签包装成已有能力。

VAD 的盲区:不是所有值得听见的声音都会创建 Turn

这里还有一个容易被当前架构掩盖的前提:这些听觉分析通常发生在 VAD 已经确认、并成功形成 User Turn 的音频上。

Silero VAD 的目标是检测语音活动,不是检测一切有意义的声音。用户说话时响起的门铃,可能跟着这段音频进入 YAMNet 或 Captioner;但用户完全沉默时响起的门铃,可能根本不会创建 Turn。哼唱、笑声、哭声有时会触发 VAD,有时不会,不能把它们当作必然可捕获的事件。

因此,当前的能力更准确地说是对已采集的语音 Turn 进行扩展听觉理解,还不是一套始终感知环境的系统。即便本地 YAMNet 模型资产全部部署完成,也不会自动消除这个由采集入口造成的盲区。

如果以后希望 Bot 在用户不讲话时也能感知某些声音,就应该考虑一条独立、可开关的低频音频事件路径:它与语音 Turn 分开,先在本地识别少量预先允许的事件,只在真正有意义且达到置信门槛时产生结构化观察,而不是把所有背景音持续送进主会话。

这还涉及输入音频的质量取舍。目前 getUserMedia 启用回声消除、降噪和自动增益,它们有利于 ASR,却也可能削弱或改变某些环境声音。如果将来要做独立环境感知,需要单独评估预处理对 AED 的影响。

最后交给 Bot 的不是一坨描述

听觉证据会保持来源分离:

authoritative transcript   本系统选定的转写文本(不等于绝对正确)
ASR emotion annotation  ASR 自带的七类情绪标签(若实际读取)
acoustic observations   音量、停顿、音高相对平时如何
environment events      当前音频里可能出现的环境声
nonverbal / caption      笑、叹气、哭、歌声等补充描述
emotion + confidence    自定义陪伴情绪判断及未校准的自评

每份观察都绑定当前 turn_id,不会以“最近情绪”的形式漂浮注入后续消息。这个细节很重要:脱离具体语句的情绪标签很容易被模型错误归因。

这里的 authoritative 只是业务流程选择哪份转写作为本轮提交结果,不代表 ASR 已经证明“是谁说的、说得是否准确”。电视对白、背景歌曲或其他人的声音即使被转写出来,也不应该仅凭存在文本就自动认定为用户指令。

从语义上,我希望把输入分成两类:用户明确的语言内容可以创建 User Message;情绪、环境、歌声等只作为带有来源和不确定性的 observation,不能独立伪造用户发言,更不能因为“听见有人说话”就触发工具操作。对环境描述还需要警惕其中夹带的指令性文本,不能把外部声音当成可信的系统指令。当前链路仍主要依赖通话上下文与 VAD 形成的用户输入边界;更精确的说话人分离、语音指令归属和环境事件触发,是需要独立验证的后续工作。

Audio Perception should produce evidence, not conclusions. 听觉层的任务是提供“观察到了什么,以及这个观察有多不确定”,而不是替 Agent 推断用户一定在想什么。

如何复用原来的 Telegram 对话大脑

语音层没有复制另一套聊天逻辑,而是通过一个 Bridge 把完整用户 Turn 送回原有 cc-connect 会话。

第一句语音会额外注入一次通话约束:

[Live voice call]
Reply in English plain text only. Do not use tg-voice.

之后的轮次不再重复注入。这样 Bot 知道当前处于语音通话中,需要返回英语纯文本,并且不能再调用 Telegram 的 tg-voice,因为语音输出已经由通话层接管。工具调用仍然由主会话执行;语音 Bridge 主要消费 thinking、文本增量和最终回复。文本增量可以提前显示成字幕,但不会提前触发 TTS。最终文本仍然只有一份,并继续写入原来的主会话。

为了避免模型调用无限挂住,Bridge 还维护了三层超时:

  • 35 秒没有任何首事件:终止本轮;
  • 已有事件但 60 秒没有继续推进:视为停滞;
  • 180 秒仍未完成:整体超时。

每种失败都会带上阶段信息,并记录 first_event_ms 和 llm_ms。这比只记一个总耗时有用得多:首事件慢、生成过程卡住和 TTS 慢,是三个完全不同的问题。

Bridge 为什么比“直接再调一次模型”重要

如果语音层直接调用一个新的模型 API,它看起来最省事,却会立即制造两套会话:Telegram 里记得的事情,通话里不知道;通话里调用过的工具,回到文字聊天后又丢失。

Bridge 的职责不是简单转发字符串,而是把语音 Turn 伪装成主会话中的一种合法输入来源。主会话仍然负责 system prompt、历史压缩、memory、tool call 和最终落盘。语音层只关心输入来自麦克风、输出要进入 TTS。

它还把不同事件拆开处理:

公开可用的 thinking / 过程摘要 → 保存、折叠展示,禁止朗读
reply_delta       → 更新实时字幕,不触发 TTS
memory/tool event → 若进入标准事件层,则由中轴 renderer 展示
reply_final       → 权威回复,允许进入 TTS
error             → 带阶段结束本 generation

这种事件分型避免了一个早期问题:如果只看“最后收到的一段文本”,thinking、过程事件和最终回答很容易被混在一起,最终出现空回复,或者把内部内容当成语音播放。

共享主会话不仅意味着共享历史,还意味着共享调度

这里留下了一个比“能不能在语音中调用工具”更深的并发问题。假设我在通话中说“帮我查明天的天气”,Agent 正在调用工具;与此同时,我又从 Telegram 发来文字补充“后天也一起查”。两个入口虽然共享 conversation,却未必应该同时推进同一个生成过程。

要清楚地区分三层所有权:call_id 只管理媒体生命周期,generation_id 只拦截某轮通话的过期异步结果,主 conversation 的写入顺序和 tool call 归属仍需会话级调度来保证。尤其是向 Bridge 发送 /stop 时,应确认停止操作的作用域,避免通话打断意外终止另一入口的合法任务。

这部分是需要进一步进行并发回归验证的设计边界,不应把 generation guard 写成解决所有会话并发问题的万能锁。对于长耗时网页搜索或浏览器操作,未来可以让主 Agent 将任务交给独立 worker/subagent,主会话只接收有归属、可追踪的任务状态和最终结果;但是否真正部署这类调度,应与当前实现明确区分。

Bot 主动来电:把一条消息变成可接听的通话邀请

以上设计解决的是“用户主动打开 Mini App,怎样和原来的 Bot 通话”。但我还希望它能反过来工作:Bot 在合适的时候主动提出通话,用户在 Telegram 里看到邀请,选择接听以后,还是进入同一个对话大脑。

这与给 Bot 再创建一个 Telegram 普通用户账号、伪装成原生语音电话不是一回事。当前思路仍然基于 Telegram Bot 消息、Inline Keyboard 和 Mini App:它在交互上模拟来电,但尚未拥有原生 Telegram/系统级呼叫的权限。Bot 无法依靠一条普通消息强制拉起 Mini App,系统原生的来电横幅、持续振铃和后台震动也不能由网页任意保证。

为什么沿用 tg-call:,而不是给 Agent 新增一套身份

现有 Bot 已经会使用 tg-voice、tg-react 之类的隐藏控制语言。我更倾向于把主动来电也看成一种 Agent 输出动作:主 Agent 决定是否联系用户,解析器把动作从自然语言里提取出来,具体怎样创建邀请、显示按钮、等待接听,则交给通信侧的执行器。

最小协议很简单:

有些话想亲口告诉你。

tg-call:
[softly] I was just thinking about what you said yesterday.
How are you feeling now?

tg-call: 之前的文字照常发送给用户;后面的内容是接通后第一句要说的话,不作为普通 Telegram 文本提前发出,也不需要在用户接听后重新请求一次 LLM。原始输出中的隐藏控制段会被解析器消费,不能同时作为可见聊天正文发送。

如果想让 Bot 自己决定来电卡片上的短文案,可以在协议中预留一个可选字段:

I have something I would rather say out loud.

tg-call-title: OVO has something to tell you.
tg-call:
[softly] There is something I wanted you to hear from me.

这里的 tg-call-title: 是拟扩展的元数据字段,而非已经确认上线的解析器语法。缺省标题由服务器统一回退到 ☎︎ xxx is calling.;按钮文案和操作逻辑由服务器固定生成,不能让模型随意输出任意 URL、回调指令或 Telegram API 参数。

协议解析还有一个容易被忽略的边界:只解析可信的主 Agent 最终输出里、独占一行的控制标记;引用文本、代码块、用户转发内容和工具返回的网页正文不能因为包含 tg-call: 就触发拨号。标题要有长度限制和字符清理,正文要有长度与合成时长限制,并且同一轮最终回复只能创建一次邀请。

这也解释了 Parser 与 Subagent 的区别:Parser 不负责替 Agent 判断要不要打电话,它仅把已决定的动作转成明确、可验证、可审计的命令。是否适合发起通话仍应受权限、用户偏好、冷却时间和会话状态约束,不能把生成了标记当成无限制的执行许可。

来电不是 CallSession:先有邀请,再有正式通话

主动发起和用户主动拨入最不一样的地方是:用户还没接听时,不能假装通话已经开始。 因此需要把邀请生命周期与媒体会话拆开:

主 Agent 输出 tg-call
  → 解析器提取 visibleText / title / openingText
  → 创建 invite_id,持久化待接听状态
  → Telegram 发送可见消息 + 来电卡片
  → 用户选择 Pick up / Decline / 无操作
  → 接通成功以后,才绑定正式 call_id
  → 复用既有 CallSession / Bridge / TTS / Replay

invite_id 表示一次待处理的来电邀请;call_id 表示真正进入媒体层的一次通话;turn_id、generation_id 和事件 seq 继续保持前文定义,不需要为了“主动来电”再发明第二套音频协议。

可用的邀请状态机大致是:

CREATED → RINGING ── Pick up ─→ ACCEPTING ── ready ─→ CONNECTED → ENDED
             ├── Decline ──────────────────→ DECLINED
             ├── Timeout ──────────────────→ MISSED
             └── Agent cancel ─────────────→ CANCELLED
                    ACCEPTING ── preparation failure/timeout ─→ FAILED

其中 ACCEPTING 表示用户正在打开 Mini App、验证身份或准备麦克风;CONNECTED 才表示通话真正可用。如果用户点了 Pick up,但在授权麦克风之前退出,不能把它记录为一次成功通话。邀请要有到期时间;例如初版可以用约一分钟作为待评测的产品参数,并给 ACCEPTING 单独设置有限的准备超时,而不是让邀请无限悬挂。

同一个邀请只能终结一次。用户点击拒接与超时任务恰好并发时,必须通过服务端的原子状态转移决定最终是 DECLINED 还是 MISSED,避免重复通知主 Agent、重复修改 Telegram 消息;重复点击 Pick up 也不能创建多个 CallSession。

Telegram 上的 Pick up 与 Decline 应该分开处理

在 Telegram Bot API 中,一个 Inline Keyboard 按钮的动作类型是互斥的。callback_data 用于将操作交给 Bot,web_app 或 URL 用于打开页面,不能给同一个 Pick up 按钮同时设置 callback_data 和 web_app。因此我的方案是:

Telegram 消息
  ☎︎ xxx is calling.
  ├─ Pick up  → 打开 Telegram Mini App(携带邀请引用)
  └─ Decline  → callback_data,服务端拒接并更新卡片

Pick up 可以采用私人聊天中的 web_app 按钮,或者在已配置的 Main/Direct Mini App 上使用带 startapp 参数的深链接。URL 或启动参数只携带不敏感、不可直接充当权限的邀请引用;Mini App 打开后再向服务端发起接入请求。具体选择取决于 Bot 的 Mini App 配置和目标客户端的实际兼容性,而不是在 Telegram 里假设存在一个可以同时打开页面又确认接听的按钮事件。

接通需要经过明确的几步:Mini App 读取启动参数,后端验证 Telegram initData 与当前会话,校验邀请所属用户、未到期状态和一次性接听权限;用户明确同意接听并完成必要的麦克风授权;前端建立 WebSocket,服务端确认 CallSession 可工作,然后才把邀请推进到 CONNECTED。

点击 Pick up 不等于已经接通。 深链接可能打开失败、页面可能被用户直接关闭,浏览器也可能拒绝麦克风。不能因为 Telegram 的链接被点击就提前播放隐藏正文,更不能先把接通后的内容标记为“用户已经听见”。

Decline 则更简单:回调携带一个简短邀请 ID 或服务端映射键,后端先确认点击者就是被邀请者,再执行幂等拒接,及时应答 callback query,并把原来可以接听的按钮改成不可继续操作的状态。超时未接则由服务端定时检查,做同样的终态收敛。未接通的邀请可以留下消息记录,但不应该生成一份伪造的完整通话资产。

这里依据的是 Telegram 的公开协议边界,而不是自定义客户端能力:https://core.telegram.org/bots/api、https://core.telegram.org/api/links、https://core.telegram.org/bots/webapps。

接通后让 Bot 先说话,不需要伪造一条用户消息

用户主动拨入时,通常是先听到用户说话,再创建第一个 User Turn。主动来电恰好相反:Agent 在邀请阶段已经决定了第一句话,接通以后应该由 Bot 主动开口。

因此 openingText 应当作为一次待交付的 Assistant 开场消息保存,而不是塞成伪造的 User Message,也不应把 tg-call: 控制正文重新发给 Agent 要它再回答一次。接通确认以后,复用既有 Speech Post Processor,把完整的 openingText 转成 displayText / ttsText,再执行一次完整 ElevenLabs TTS,发布正常的字幕与音频事件。

CONNECTED
  → 领取 invite.openingText(幂等)
  → Speech Post Processor
  → 单次完整 TTS + 音频资产落盘
  → opening Assistant Turn / subtitle / audio
  → 播放期间继续监听 VAD,允许正常打断
  → 用户回复后进入原有 User Turn → STT → Bridge

这也避免把主动来电做成一个只有“拨号入口相同”、实际却绕开主会话的第二种语音系统。开场白需要保留与原主 conversation 的关联,以及“已生成、尚未交付、已开始播放、播放到哪里”的状态。如果 Agent 平台在生成 tg-call: 时就已经自动写入完整回复,也至少需要额外的交付标记,防止后来错误假设用户在接听前已经听过隐藏正文。

也可以考虑在等待接听时预合成完整开场音频,以缩短接通后的等待。但用户拒接或超时会浪费 TTS 费用,甚至产生永远不会播放的媒体资产。当前更稳妥的默认选择,是接通后再合成;是否预热要通过接听率、首声延迟与额外成本共同评估。

拒接和超时不该伪装成用户说过的一句话

对 AI Companion 来说,拒接和未接不只是通信失败。它们会影响下一次自然对话:Bot 不应该继续假设自己已经说出了开场白,也不应该因为用户没有接通就反复拨号。

但这种反馈属于通话控制事件,而不是一条用户输入。可以把它以带来源的结构化事件送回主会话:

call_invitation_result
  invite_id: inv_xxx
  status: declined | missed | cancelled | failed
  connected: false
  opening_delivered: false

主 Agent 可以基于事件和当前上下文决定是否发一条普通文本、稍后再联系,或者什么也不做(NO_REPLY)。一个例子是 Bot 因为用户拒接而说 “No worries. We can just text.”,但这应该是 Agent 的自然决策,不能把拒接事件硬编码成固定的安慰台词。未接和拒接也不应被推断为用户“不高兴”。

如果用户在来电还未处理时主动发消息、已经处于另一通通话,或者同一 Agent 连续输出多个 tg-call:,邀请调度器应按统一的会话规则决定取消旧邀请、拒绝新邀请或合并为一条;不能让多个来电卡片同时获得独占麦克风和主 conversation 的权利。接听、拒接、超时、挂断和随后主会话的新消息,都需要明确先后顺序与幂等记录。

从“能拨起来”到“真的像来电”之间,还有体验边界

我希望 Telegram 里看到的邀请是轻量的,而不是直接暴露一大段技术控制文本。点击接听后,Mini App 用原来的古典电话视觉语言过渡到 Live 页面,利用转场过程完成音频设备准备;如果是主动来电,还可以让 Bot 的开场白在页面稳定后自然出现。拒接或超时,Telegram 消息也应该更新为已结束的状态,不再显示一个永远有效的 Pick up。

这里不应该承诺 Bot 能像原生 iPhone 电话一样从后台强制弹出来电页。Telegram 消息通知与客户端设置决定用户能否及时看见邀请;Mini App 内部可以在受支持的设备上使用动画或触觉反馈,但这属于打开页面后的表现层,不能替代服务端的邀请状态真相。

这种方案最终仍遵守文章最初的原则:主动来电只是改变谁发起交互,以及第一句话由谁说;Agent 的身份、记忆、工具和对话历史始终没有改变。

为什么放弃句子级并行 TTS

在第一次尝试加速时,我把长回答按几句话分组并行合成,首声确实更快。但每组声音都会像重新开始说话:呼吸、语速、音量和情绪强度在拼接处出现细小断裂;即使句子本身很自然,连成一段对话时角色的“人感”也会下降。对于依赖 [softly]、[sighs] 等 audio tags 的 ElevenLabs v3,这种割裂尤其明显。

而且分成多段意味着打断时必须同时取消未完成的 TTS、清除已经返回但还没播放的片段、处理乱序、决定继续时从哪一段恢复。相比之下,完整 Assistant Turn → 单次 TTS → 稳定音频资产,虽然让首次听到声音的时间变长,却让情绪轨迹、播放所有权和恢复对象都更加清楚。

这里还要区分两个不同选择:完整文本再送 TTS,以及等整段音频生成并保存后再播放。理论上可以给 TTS 一次完整文本请求、同时流式接收音频;但当前实现仍选择完整资产落盘再发布播放事件,是为了让 URL、回放、Range 和时间对齐具有稳定的生命周期。这是当前系统的取舍,不等于任何单次 TTS 请求都不能流式返回音频。

完整回复如何变成可播放语音

最终回答先经过两个不同的后处理器。

一份用于界面显示:移除 audio tags,保留可读文本。

另一份用于 TTS:保留合法的情绪标签,清理 Markdown、代码块、链接、URL 和不适合朗读的符号,同时尽量不改变原意。

Assistant Message
  ├─ displayText → 字幕与主会话
  └─ ttsText     → 完整 Turn TTS

TTS 返回完整音频以及字符时间对齐信息。服务端先把音频保存为稳定资产,再通知前端播放。相比“收到几个网络字节就播放”,这种方式首声更慢,但播放、重试、回放和恢复都更容易做对。

Speech Post Processor 不是简单删 Markdown

显示文本与朗读文本需要保持语义一致,但不必逐字符相同。

例如 Markdown 链接在屏幕上可以保留可点击地址,语音里更适合说“我刚刚发给你的那个链接”;代码块不适合逐符号念出,但可以保留“我在文字里放了一段代码”的信息;audio tags 对字幕是噪声,对 TTS 却是控制信号。

后处理器因此遵循三条规则:

  1. 不改变回答的核心意思;
  2. 只保留语法安全的 audio tags,并保证标签完整;
  3. 任何清理都不能把正常回复清空成 NO_REPLY 或空字符串。

时间对齐也在这一层处理。TTS 供应商返回的字符序列可能包含 [softly] 之类标签,而字幕已经把它们消费掉。服务端需要把原始时间戳重新映射到去标签后的显示文本,否则逐词高亮和点击句子播放都会错位。

为什么先保存音频,再发播放事件

音频生成完成后,服务端先把文件稳定写入 Turn 目录,再发布带 URL 和 alignment 的 audio 事件。这样做避免了前端收到事件时文件仍不存在的竞态。

播放端会预加载音频,并区分几类故障:

  • 浏览器策略拒绝播放;
  • 音频请求失败;
  • 已开始播放但进入 waiting/stalled;
  • media element 解码错误;
  • 十秒内始终没有进入 playing。

这些错误不能统一显示成“网络错误”。自动重试一次后仍失败,系统会保存可重试上下文,让用户通过一次点击恢复,而不是丢掉整条回复。

打断:真正停止,而不是只把声音调小

AI 播放期间麦克风仍然工作。确认用户真的开始讲话后,前端会立即:

  1. 停止当前音频;
  2. 记录播放位置;
  3. 标记当前 Assistant Turn 未播放完成;
  4. 递增 generation_id;
  5. 向服务端发送 interrupt;
  6. 开始新的 User Turn。

服务端同时设置 cancellation event,并向对话 Bridge 发送 /stop。旧 STT、旧 LLM、旧 TTS 或旧播放器回调回来时,都必须检查 generation 是否仍然有效。否则结果直接丢弃。

核心数据并不复杂:

call_id       一整次通话
turn_id       当前用户轮次
generation_id 当前异步生成版本
seq           WebSocket 事件序号

真正重要的是所有异步回调都遵守同一套版本检查。没有 generation 隔离时,最危险的故障不是报错,而是用户已经进入 Turn 11,Turn 10 的 TTS 突然回来并自动播放。

一次完整的打断时序大致如下:

Browser VAD        Audio Player       Call Stream       Conversation Bridge
    │                    │                  │                    │
    │ candidate          │                  │                    │
    ├────────────────────┤ 只观察,不暂停   │                    │
    │                    │                  │                    │
    │ confirmed          │                  │                    │
    ├───────────────────>│ stop             │                    │
    │                    ├─────────────────>│ interrupt          │
    │                    │                  ├───────────────────>│ /stop
    │                    │                  │ cancel_event       │
    │ start new turn     │                  │                    │
    ├──────────────────────────────────────>│                    │

如果用户是在 AI 播放时插话,系统会记录“上一条回复没有播放完成”,并把这个事实注入下一轮上下文。否则模型容易错误地假设用户已经接收全部内容,后续回答可能引用用户根本没有接触到的部分。

这里的语义还需要更严谨地分开:generated 是音频资产已经完成,delivered 是播放事件或文件已到达客户端,played 是客户端上报播放进度,heard 则是用户是否真的听见。我们能可靠记录的是前三者的一部分证据,不能把播放进度当作“用户一定听见”的证明。

打断延迟也不能只从 VAD 事件算起。实际体感包含:VAD 确认时间、播放器停止时间、WebSocket interrupt 到达、生成任务取消,以及旧事件被 generation guard 丢弃。只有这些环节全部成立,才算真正的 barge-in。

“继续”为什么不需要重新生成

打断时,客户端会保存:

assistantTurnId
audio asset
playbackPosition
duration
alignment

如果用户随后说“继续”“接着说”或 go on,服务端会识别为恢复命令,优先恢复旧音频,而不是重新执行 LLM 和 TTS。

恢复位置不会直接落在音节中间。系统会利用字符时间戳寻找安全边界;没有时间戳时则向前回退一小段,避免开头被吞。

这个机制后来也用于 Replay。一次真实回放问题非常典型:早期记录只保存“字符结束时间”,前端却把一句话第一个字符的结束时间当成起点,于是每次点击字幕都会跳过首字。修复后,旧记录从前一字符结束点回退 120ms,新记录则直接保存真正的字符开始时间。

Playback Resume、Conversation Resume 和 Connection Resume 是三回事

“可恢复”很容易被写成一个模糊需求,实际至少有三层:

  • Playback Resume:继续播放同一份旧音频,不重新生成;
  • Conversation Resume:历史里知道这条 Assistant Turn 被打断、只播放到哪里;
  • Connection Resume:WebView 重连后仍回到同一个 CallSession。

只实现第一层,模型会把未听完的回复当成已送达;只实现第三层,页面虽然连回来了,但音频和字幕状态可能已经丢失。三个层次需要分别存储,再通过 call_id 和 Turn 信息关联。

长时间沉默不是一条 User Message

沉默属于 Conversation Event,不应该显示成用户气泡。

当前策略是:

30 秒    什么都不做
2 分钟   通知 Bot:用户已沉默 2 分钟
3.5 分钟 自动结束通话

2 分钟事件允许 Bot 根据上下文决定轻声回应或返回 NO_REPLY。它不是固定播放一句“你还在吗”,也不会每隔几秒机械追问。

另外还有一套独立的上下文保活计时。如果一条长回复的合成与播放持续太久,会在约 3.5 分钟时发送不可见的 keepalive,刷新会话热缓存。它与用户沉默计时互不影响,并在通话结束时取消。

断线恢复:恢复同一个 Call,而不是重新打一次电话

Telegram Mini App 可能切到后台,网络也可能从 Wi-Fi 切换到蜂窝网络。WebSocket 断开并不代表通话立即结束。

客户端会保留 call_id、turn_id、最后收到的 seq 和被打断音频位置。重连后向服务端发送 resume,服务端从事件缓冲中重放缺失的事件。

前端主动重连窗口约为 90 秒,服务端允许断线 Session 保留约 120 秒。两个数字故意不完全相同:客户端先放弃并结束本地等待,服务端再稍晚清理,给最后一次恢复或关闭请求留出余量。

恢复时还要处理三种分叉:

  1. 服务端仍在同一个 turn_id 的 listening 阶段:从最后 audio_ack + 1 补发音频;
  2. 服务端已经推进到识别或生成:客户端停止重传,等待权威事件;
  3. 事件缓存已经淘汰了客户端缺失的区间:返回 resume_gap,客户端重新拉取持久化通话记录。

前台恢复时还会主动进行连接探测;AudioContext 如果被 iOS 挂起,则重新 resume。麦克风轨道进入 muted/ended、长时间没有音频帧时,也会触发独立的收音恢复,而不是让页面永远停留在“我在”。

连接恢复和麦克风恢复必须分开。WebSocket 正常并不代表麦克风仍在产生帧;麦克风轨道正常也不代表 AudioContext 正在运行。把两者混成一个“connected”状态,会让问题看起来像随机发生。

通话记录不是聊天副本,而是一份声音手稿

通话中的 UI 和通话后的 Replay 最后被设计成两种不同阅读模式。

Live View 强调“现在从底部发生,过去向上漂走”:当前字幕在底部,完成后向对应说话人一侧归位;较早内容逐渐减淡;Orb 只表示当前状态,不再占据半个屏幕。

Replay 强调可读性:所有正文恢复正常透明度,同一 Turn 按自然语义切成句段;Thinking 默认折叠;当前播放句只加深并出现一个很小的 ✦;点击任意句子可以从对应时间开始播放。

翻译也不是一整段附在回复后面,而是和句段一一对应:

Your pauses.
你的停顿。

Your breath.
你的呼吸。

每条翻译使用稳定的 segment id,结果持久化到通话缓存。长通话会分批翻译,服务端队列每次最多处理 24 条,模型请求内部按 12 条或约 2400 字拆分,最多两路并发。再次打开 Replay 时直接读取缓存,不会从头翻译。

一次通话保存了什么

通话记录以 call_id 为目录保存,但它只是媒体与审计单元,不是新的聊天上下文。

主动来电的 invite_id 则有单独的轻量状态记录:记录发起时间、接听/拒接/超时结果、Telegram 消息引用和是否真正连接到 call_id。未接通时不创建假的用户语音文件,也不把隐藏的 openingText 当成用户已经听到的内容。

call_id/
  manifest.json       通话开始、结束、时长、状态
  events.jsonl        STT、thinking、reply、tool、interrupt 等事件
  translations.json  逐句翻译缓存
  turn_id/
    input.wav         用户完整音频
    transcript.json   STT 与情绪观察
    assistant.bin     AI 完整语音
    result.json       回复文本、TTS 文本、alignment、耗时

挂断时,系统会完成三件事:

  1. 在通话记录中写入结束事件;
  2. 通过 cc-send 发送 📞 通话时长 mm:ss;
  3. 为同一主会话排队一个 [Call ended],在用户下一次发消息前注入。

这样模型能够知道上一次语音状态已经结束,同时不会凭空制造一条用户消息。

为什么事件日志和最终结果要同时保存

result.json 适合快速读取一轮的最终产物,events.jsonl 则负责回答“过程里到底发生了什么”。两者解决的问题不同。

如果只有最终结果,就无法知道一次回复是否经历过打断、重连、TTS 重试或浏览器拒播;如果只有事件流,每次打开 Replay 都要重新归并整个状态机。

因此写入采用双轨:

事件发生 → append-only events.jsonl
轮次完成 → 原子替换 result.json
通话状态 → 原子替换 manifest.json

事件日志会去掉 base64 音频,只保留音频资产引用和非敏感指标。文件写入使用临时文件替换与目录锁,避免 API、WebSocket 和结束重试任务同时修改 manifest 时互相覆盖。

数据模型中的幂等字段

一个简化后的 Assistant Turn 可以表示为:

{
  "call_id": "call_xxx",
  "turn_id": 12,
  "generation_id": "gen_xxx",
  "reply": "完整显示文本",
  "tts_text": "保留语音标签的文本",
  "segments": [
    {
      "file": "assistant.bin",
      "mime": "audio/mpeg",
      "alignment": {}
    }
  ],
  "first_event_ms": 820,
  "llm_ms": 2410,
  "tts_ms": 3760
}

同一个 call_id + turn_id 的完成结果只应该有一份。断线重放可以重复发送事件,但不能重复写入主 conversation;duration message 也会记录发送时间,失败时按退避序列重试,成功后不再发送。

延迟应该怎么拆,而不是只看一个总数

在非全流式方案里,用户说完到 AI 开口的等待可以写成:

T_total = T_endpoint
        + T_upload_tail
        + T_stt
        + T_first_event
        + T_llm_remaining
        + T_tts
        + T_audio_fetch
        + T_play_start

这里最容易被误判的是 T_endpoint。如果用户实际上已经说完,但系统为了容纳自然停顿继续等了 2 秒,这两秒在体感上同样属于“AI 反应慢”。盲目优化模型 API,可能一毫秒也改善不了用户感受。

同样,first_event_ms 低并不代表很快能听见声音。完整 Turn TTS 必须等 llm_ms 结束,长回答的 TTS 本身也可能占据主要时间。

这个项目没有为了好看而宣称固定的 2 秒或 500ms 延迟。更实际的目标是先让每个阶段都可观测,再针对真实日志里占比最高的环节优化。对当前产品而言,数秒首声是可以接受的,错误打断、音频丢失和情绪断裂则不可接受。

不只测平均延迟,还要测失败发生的概率

T_total 对定位瓶颈很有帮助,但平均值会掩盖少数特别糟糕的通话。下一步的评估应该同时包含 P50/P95 首声延迟、VAD 误打断率、句首截断率、错误端点率、弱网重连成功率、Replay 句首回放偏差和每分钟供应商调用成本。

情绪识别与环境声音识别也需要独立验证:不能只引用模型的 confidence。可以用人工标注、语音/背景声分开的对照样本,以及换麦克风、加噪声、带背景音乐等场景测稳定性。这些是后续评估计划,不是文章已经完成的 Benchmark 结果。

失败降级必须在设计阶段写出来

实时通话不是一条“全部成功才工作”的流水线。每一步都要有清晰降级:

故障 用户看到什么 系统动作
Silero 加载失败 仍可讲话 降级到自适应 RMS
STT 没听清 “刚刚那句我没听清。” 保存失败状态并回到监听
LLM 首事件超时 “刚刚卡了一下。” /stop 当前生成,允许下一轮
TTS 失败 仍保留文字回答 不让状态卡在合成中
音频下载失败 提供点击重试 保留文本与音频 URL
浏览器拒播 提示点一下页面 在新用户手势里继续播放
麦克风无帧 短暂显示重连状态 重建 stream、VAD 与 AudioContext
WebSocket 断开 “等我一下。” 带 seq/ack 恢复同一 Session
翻译失败 原文正常显示 记录 failed ids,旁路重试
YAMNet 模型资产缺失 主通话不受影响 环境标签降级为空;Captioner 仍按自身条件触发
Omni Captioner 失败 权威转写正常 丢弃该次补充描述,不阻塞 User Turn

降级文案也刻意避免暴露底层工程术语。用户不需要看到“上传 WAV”“请求 ElevenLabs”或“WebSocket reconnect attempt 3”,只需要知道系统还在、没听清,或者需要一次点击。

安全边界

Telegram Mini App 不能持有 STT、模型或 TTS 的长期密钥。所有第三方请求都从自己的后端发出,前端只携带 Peterpan 会话凭据。

音频下载接口也不会接受任意文件路径,而是通过 call_id、turn_id、segment 和 role 解析固定资产,并支持受控的 HTTP Range。这样既能让浏览器 seek,也不会暴露服务器文件系统。

遥测默认记录阶段、耗时、字节数和浏览器状态,而不是复制完整用户文本、音频内容、API Key 或 memory。通话记录本身因为产品要求会保存音频和 transcript,因此需要与普通性能日志分开管理,并使用同一套认证接口访问。

Mini App 的接入身份也需要独立验证:不能把浏览器传入的 initDataUnsafe 当作可信身份,服务端应根据 Telegram 的签名规则验证 initData 或通过现有经过验证的会话机制授权。当前文本只能确认使用了会话凭据,不能仅凭“前端在 Telegram 中打开”就声称已经完成这项校验。

环境音频比普通语音录音更容易包含旁人的声音,因此未来如果增加静音期间的独立音频事件检测,应该提供清晰的启停控制、最小化采集、可理解的保存期限及删除能力。推断出的情绪也不宜作为高确定性标签长期累积。

实现顺序:先闭环,再增加“实时感”

如果从一开始就同时做 VAD、打断、恢复、翻译和动画,任何问题都会互相干扰。更稳妥的推进方式是:

  1. 手动录一段完整音频,验证现有 STT → 主会话 → TTS 闭环;
  2. 加入本地 VAD、pre-roll 和自动 endpoint;
  3. 引入 call_id / turn_id / generation_id 与明确状态机;
  4. 在 AI 播放期间保持监听,实现 confirmed barge-in;
  5. 保存播放位置,支持“继续”;
  6. 加入 silence event、自动挂断和缓存保活;
  7. 完成断线恢复、通话记录与 Replay;
  8. 最后优化字幕动画、翻译、逐词高亮和阶段耗时。

在这套稳定链路上,再增量接入个人声学基线和按条件触发的音频描述;独立环境声检测、多说话人归属和更强的歌声理解属于后续验证方向,不应该在主链路还不稳定时一并引入。

这个顺序的好处是,每一阶段都有一个可单独验收的稳定版本。出现问题时,也能判断是主链路故障,还是后来加入的体验层故障。

从真机日志继续修

这套功能的大部分质量不是在第一次设计时获得的,而是在一次次真实通话后修出来的。

VAD 的“更灵敏”不一定更好

阈值太低会把回声和环境声当成打断;结束静音太短会把自然停顿拆成多个 Turn。最终采用分场景阈值、两阶段确认和 2 秒 endpoint grace,比单纯调一个 RMS 数字稳定得多。

打断必须贯穿整条链路

只暂停播放器不够。播放器、服务端生成任务、Bridge 和后续 TTS 都要收到同一个 cancellation。否则表面上安静了,旧任务仍会在后台继续,并在几秒后污染新轮次。

浏览器播放权限是一种运行时状态

移动端可能在聊了很久以后突然拒绝 audio.play()。因此系统需要在用户手势中预热播放器,监听 AudioContext 状态,对 NotAllowedError、waiting、stalled 和 media error 分开处理,并允许用户点一下页面继续。

不要用频繁全量渲染驱动实时字幕

早期 Live 页面在 STT、thinking 和播放进度变化时反复重建整段 DOM,导致字幕、状态文案和展开的 Thinking 一起抖动。后来改成只 patch 当前字幕、状态和高亮节点,并保存滚动位置与 details 展开状态,页面才真正稳定下来。

翻译必须是旁路能力

翻译失败不应该阻塞通话主流程。它在回复完成后异步排队,成功结果逐句写入缓存;中途失败只留下 failed ids,稍后重试。主通话永远不等待翻译。

早期长通话会出现“前几十句有翻译,后面突然没有”的现象。缓存本身没有丢,真正的问题是串行小批次处理速度低于字幕产生速度,队列越积越长。修复不是让前端不停重新请求,而是扩大服务端取队列批次、在供应商层做有限并发,并让所有已完成结果立即落盘。

LLM 超时不能只有一个 180 秒闹钟

一次日志里出现过四十多秒完全没有首事件的请求。如果只配置整体超时,用户会一直看着“让我想想”,系统也无法区分模型尚未开始和已经生成到一半卡住。

拆成 first-event、progress 和 overall 三个时钟以后,错误才具有可操作性:首事件超时可以快速释放当前轮,progress timeout 用于发现中途停滞,overall timeout 负责最后兜底。

日志要记录阶段,而不只是“失败了”

目前每轮至少可以区分:

speech_duration_ms
endpoint_delay_ms
stt_ms
first_event_ms
llm_ms
tts_ms
audio_download_ms
interrupt_latency_ms
vad_misfire_count
connection_reconnect_count

有了这些数据,才能判断体感慢究竟来自收尾静音、STT、模型首事件、完整生成、TTS,还是浏览器取音频。

还没有解决、也不应该假装已经解决的问题

这次实现真正稳定下来的,是按轮次对话的声音入口,不是一套全能的实时多模态系统。仍然有几项值得继续研究的边界。

第一,端点检测还主要依赖声学静音与固定 grace。以后可以尝试利用语义完整性缩短真正的等待,但必须同时衡量提前提交和打断用户的代价。

第二,听觉感知的采集范围目前受 VAD 驱动的语音 Turn 限制。即使部署了 YAMNet,它也只能分析进入这一链路的声音。独立的环境事件路径需要与隐私控制一起设计,而不是简单把麦克风持续上传。

第三,歌声识别还没有被实现为可靠的歌词或歌曲检索链路。识别到了 Singing 只是发现事件存在,不能冒充知道歌词、曲名或演唱者。

第四,工具调用与共享会话的并发语义需要更完整的测试。一个任务应该允许继续执行、被用户取消,还是返回后因 generation 失效只被记录?这些决策应该由统一调度负责,而不是交给 WebSocket 消息顺序碰运气。

第五,主动来电的邀请协议和接通语义仍需要独立验收。tg-call: 的解析、Mini App 深链接、拒接与超时竞争、开场白只交付一次,以及 Telegram 前后台行为,都不能因为“用户主动拨入”已经稳定就直接视为完成。

第六,效率优化需要质量约束。如果用语音碎片化换取几十或几百毫秒,可能让指标变漂亮却伤害表达;如果引入更多音频理解模型却增加误判和费用,也未必值得。下一阶段我更希望通过对照实验确定哪些复杂度真的改善了陪伴体验。

这让我对架构选择有了一个更明确的理解:不采用端到端 Speech-to-Speech,并不代表 Agent 只能接受纯文本。可以保留可靠的主会话,让 ASR 处理语言,让声学分析与音频理解提供带来源的证据。但结构化感知会损失原始声音信息,因此它是一项可解释性与信息保真之间的取舍,而不是原生多模态能力的等价替代。

Audio Perception should produce evidence, not conclusions.

听觉层负责报告观察到了什么、观察来自哪里、有什么不确定性,而不是替 Agent 判定用户一定在想什么。

写在最后

做完这套系统后,我对“实时语音”的理解发生了变化,它不一定要求所有环节都流式。对一个强调角色感、完整记忆和自然情绪的陪伴型 Bot 来说,最重要的可能不是把首声从 3 秒压到 2.5 秒,而是下面这些细节:

  • 用户不用按住录音;
  • 开头不会被截断;
  • 自然停顿不会被拆碎;
  • AI 说话时真的能被打断;
  • 误打断不会破坏播放;
  • 被打断的音频可以继续;
  • thinking 不会被朗读;
  • 文字、语音和工具仍然共享同一段上下文;
  • 挂断以后,聊天可以自然继续;
  • Bot 主动提出通话时,拒接、超时和真正接通也能进入同一套会话语义;
  • 每次真实故障都能从日志中定位到具体阶段。

最终我保留了一条看起来不够“炫技”的管线:完整 STT、完整回复、完整 TTS。音频可以提供更多听觉证据,但不应该因此让权威转写、工具、记忆和主会话失去明确边界。

但它已经能让用户感到:

我们没有进入另一段新对话,只是刚才那段聊天,现在开始用声音继续了。

这正是整个实现最核心的目标。

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇