单腿与双腿:语音机器人两种媒体腿架构原理

2026-07-10 20:24

从 FreeSWITCH 媒体腿视角剖析 park / transfer 两种挂载方式的控制平面、媒体路径、编解码责任
覆盖呼入与呼出两种场景 · 聚焦通用技术原理

1. 引言:用"腿"重新定义语音机器人

FreeSWITCH 中,一次通话由一条或多条媒体腿(leg)构成。bridge 把两条腿的 RTP 在 FS 内部直连;park 让通道单腿悬停,等待外部命令驱动。

语音机器人从 FS 媒体腿的视角看,有两种根本不同的挂载方式:

单腿:park + ESL 旁路双腿:transfer + SIP UA
通道 park 在 FS,只有 A-leg。Java 通过 ESL 旁路控制,媒体不离开 FS。
Java 是"外挂指挥官",FS 是"执行者"。
通道 transfer 到 SIP 分机,A-leg + B-leg 桥接。Java 作为 B-leg 终端接管 RTP。
Java 是"媒体中转网关",承担编解码责任。

腿数决定了媒体路径、控制平面、编解码责任、延迟构成的根本差异。本文从原理层面剖析这两种架构,并覆盖呼入与呼出两种场景。

2. FreeSWITCH 媒体腿原语

2.1 A-leg 与 B-leg

当一通电话进入 FS,通道创建,这就是 A-leg。当 FS 再向另一方发起呼叫并通过 bridge 连通,第二段通道就是 B-leg。两腿在 FS 内部 RTP 直连,FS 不解封装 payload,只做媒体转发。

单腿状态下,通道没有 bridge_uuid;双腿桥接后,B-leg 有独立 UUID,A-leg 的 Bridge-A-Unique-ID 指向 B-leg。

2.2 四个核心原语

原语语义腿数关键特征
park通道停在 parking lot,等待 ESL 命令驱动1FS 既是信令终端也是媒体终端
bridge两腿 RTP 在 FS 内部直连,不解封装 payload2FS 只做媒体转发,不感知 payload 内容
transfer转接到目标分机,FS 向目标发 INVITE 建立 B-leg,自动 bridge2目标可以是外线、内部分机、或 SIP UA
uuid_audio_fork把通道 PCM fork 一份到 WebSocket,只读旁路1不消耗媒体、不建立新腿、单向复制
fork 与 bridge 的本质区别:fork 是"只读旁路",原始 RTP 流不受影响,仍在 FS 内部正常转发或播放;bridge 是"媒体接管",两腿的 RTP 在 FS 内部直连。fork 不产生新腿,bridge 产生 B-leg。

3. 单腿架构原理:park + ESL 旁路控制

3.1 媒体腿结构

通道停在 FS,只有 A-leg。媒体在 FS 内部循环——无操作时静音空转,执行 playback 时播放音频。Java 不感知 RTP 包,不参与媒体流转。

主叫 ←──RTP──→ FS (A-leg, park)
                  │
                  ├── uuid_audio_fork (只读旁路) ──→ WebSocket ──→ ASR
                  │
                  └── uuid_broadcast (FS 本地播放) ←── TTS 音频

3.2 控制平面:ESL inbound

Java 作为 ESL 客户端连入 FS,通过命令远程操控通道。核心命令分三类:

类别命令作用
取音uuid_audio_fork {uuid} start {wsUrl}把 PCM 旁路复制到 ASR WebSocket,输出已是解码后的 PCM
放音uuid_broadcast {uuid} http://host/tts.mp3 alegFS 主动拉流并在通道内播放,可指定 aleg/bleg/both 方向
控制uuid_break / uuid_transfer / uuid_kill打断放音 / 转接目标 / 挂断通道

3.3 AI 管道

分层管道:ASR → Intent → LLM → TTS,每层独立网络往返。取音通过 uuid_audio_fork 旁路到 ASR;放音通过 TTS 生成音频 + HTTP 接口供 FS 拉流。Java 是"外挂指挥官"——发出 ESL 命令;FS 是"执行者"——在通道内完成媒体动作。

3.4 打断(Barge-in)

ASR 检测到用户开始说话时,Java 调用 uuid_break 中断当前 TTS 播放。媒体腿不变,只是放音动作被取消。打断延迟 = ASR 首字检测延迟 + ESL 命令往返延迟。

4. 双腿架构原理:transfer + SIP UA 桥接

4.1 媒体腿结构

FS 通过 transfer 把通道转给 Java 注册的 SIP 分机,建立 B-leg。A-leg(主叫 → FS)与 B-leg(FS → Java)在 FS 内部 bridge,RTP 直连。Java 是 B-leg 的 terminating endpoint。

主叫 ←──RTP──→ FS(A-leg) ←──RTP──→ FS(B-leg) ←──RTP──→ Java(SIP UA)
                                                                │
                                                                ├── RtpReceiver (收 PCMU)
                                                                ├── G.711 解码 → 重采样 8k→16k
                                                                ├── 模型 WebSocket
                                                                ├── 重采样 24k→8k → G.711 编码
                                                                └── RtpSender (发 PCMU)

4.2 控制平面:Java 作为对等 SIP UA

Java 用 SIP 协议栈(如 JAIN-SIP)注册分机到 FS,直接收发 SIP 信令:

  • INVITE:FS 转接时向 Java 分机发起 INVITE,Java 回 100 Trying / 180 Ringing / 200 OK + SDP Answer。
  • BYE:任一方挂断,通过 SIP BYE 通知。
  • REGISTER:Java 向 FS 注册分机,周期性刷新(Expires 通常 3600s),处理 401/407 摘要认证。

SDP 协商确定 RTP 端点:Java 在 SDP Answer 中声明自己的 RTP 端口和 payload type。Java 是对等 UA,不是外挂控制器。

4.3 媒体接管:RTP 直收直发

Java 直接接收 B-leg 的 RTP 包,并承担全部媒体处理责任:

入向(用户 → 模型)出向(模型 → 用户)
  1. FS 把 A-leg RTP 转发到 B-leg → Java RtpReceiver 收到 PCMU 包
  2. G.711 解码(u-law → 16-bit PCM)
  3. 重采样(8kHz → 16kHz)
  4. 通过 WebSocket 发给模型
  1. 模型通过 WebSocket 返回 PCM 音频
  2. 重采样(24kHz → 8kHz)
  3. G.711 编码(16-bit PCM → u-law)
  4. 构造 RTP 包,通过 RtpSender 发回 FS → 主叫
对称 RTP(关键约束):收发必须共用同一个 UDP socket。RTP 协议要求对端根据收包源地址回复媒体,如果收发端口不同,对端无法正确回复。这是双腿架构必须遵守的硬约束。

4.4 AI 管道

端到端单模型:Java 把 PCM 桥接到模型的 WebSocket,模型内部完成 ASR + 对话推理 + TTS 生成。单次 WS 往返完成完整对话循环。Java 是"媒体中转网关"——一端连 FS 的 RTP,另一端连模型的 WebSocket。

4.5 打断(Barge-in)

端到端模型自身检测用户首字,通过事件通知 Java。Java 清空本地播放队列(丢弃待发的 RTP 包),新 TTS 音频到达后恢复播放。与单腿的区别:单腿打断需要 Java → ESL → FS 三跳;双腿打断在 Java 本地完成,不涉及外部命令往返。

5. 呼入场景

5.1 呼入的通用机制

外部来电的入口是 SIP INVITE。运营商/中继网关将用户呼叫转成 SIP 信令,向 FS 的 SIP profile 发送 INVITE。INVITE 携带:

  • Request-URI:被叫号码,FS 解析为 destination_number
  • From header:主叫号码,FS 解析为 Caller-Caller-ID-Number
  • SDP body:主叫侧的 RTP 地址、端口、编码能力

FS 收到 INVITE 后创建 A-leg 通道,进入 inbound dialplan 进行路由决策。dialplan 可以是 XML 配置或 Lua 脚本。Lua 脚本的优势是支持数据库查询、动态比例路由、灵活的上下文注入。

5.2 单腿呼入流程

INVITE → dialplan → set bot_id/bot_type (channel variable)
                    → park
                    → CHANNEL_PARK 事件 → MQ 投递
                    → Java 消费 FsEventConsumer
                        → 从事件 headers 读 variable_bot_id
                        → ESL uuid_answer 接通
                        → uuid_audio_fork 启动取音
                        → AI 管道启动

上下文传递:通过 channel variable。Lua 执行 session:setVariable("bot_id", xxx),这些变量存储在 FS 通道变量表中,随事件投递到 MQ。Java 从事件 headers 中以 variable_bot_id 的 key 读取。

channel variable 只在 FS 内部可见(事件、ESL 命令),不进入 SIP 信令。这是单腿架构的上下文传递方式——信息不离开 FS。

5.3 双腿呼入流程

INVITE → dialplan → set sip_h_X-Bot-Id (SIP 自定义头)
                    → transfer 8801 XML default
                    → FS 向 Java 分机发 INVITE (携带 X-Bot-Id 头)
                    → Java SipAgent.handleInvite:
                        → 发 100 Trying + 180 Ringing
                        → 解析 SDP (FS 的 RTP 端点)
                        → 解析 X-Bot-Id 自定义头
                        → 200 OK + SDP Answer (声明 Java RTP 端口)
                    → B-leg 建立,A/B 两腿在 FS 内部 bridge
                    → 启动音频桥接线程

上下文传递:通过 SIP 自定义头。Lua 执行 session:setVariable("sip_h_X-Bot-Id", xxx)sip_h_X- 前缀是 FS 的约定:设置后,FS 在后续发出的 SIP 消息中携带对应的 X- 自定义头。自定义头随 INVITE 传到 Java,可穿越 SIP 网络。

5.4 呼入两种架构对比

维度单腿(park)双腿(transfer)
路由动作parktransfer 8801 XML default
上下文传递channel variable(FS 内部)SIP 自定义头(穿越 SIP 网络)
来电事件CHANNEL_PARK(MQ 消息)INVITE(SIP 请求)
挂断事件CHANNEL_HANGUP(MQ 消息)BYE(SIP 请求)
DTMFDTMF 事件(MQ 消息)RFC 2833 RTP 事件(媒体流内携带)
媒体建立触发Java 调 uuid_answerJava 发 200 OK 后
取音方式uuid_audio_fork 旁路复制 PCMJava RtpReceiver 直收 RTP + G.711 解码
放音方式uuid_broadcast 在通道内播放Java RtpSender 构造 RTP 包发回 FS
编解码责任FS 内部完成,Java 只处理 PCMJava 自己做 G.711 编解码 + 采样率转换
打断机制ASR 首字 → ESL uuid_break → FS 停止播放模型首字事件 → Java 清空本地播放队列
天然隔离:两种模式的路由决策点相同(都在 dialplan 的 Lua 脚本)。分支点在 Lua 内部:bot_type 决定走 park 还是 transfer。Pipeline 只认 CHANNEL_PARK 事件,双腿只认带自定义头的 INVITE,不会抢同一通电话。

6. 呼出场景

6.1 originate 命令

FreeSWITCH 的 originate 命令是主动发起外呼的核心原语。通用语法:

originate {变量列表}拨号目标 [接通后动作]
组成部分说明示例
变量列表{key1=val1,key2=val2} 包裹,设置在 originate 创建的通道上{origination_uuid=xxx,origination_caller_id_number=153xxx}
拨号目标指定呼叫谁,格式决定路由方式sofia/external/phone@gateway / user/extension
接通后动作被叫接听后通道进入什么状态&park() / &bridge(user/8801) / 9999 XML public

拨号目标格式

场景格式含义
外部 IP:Portsofia/external/phone@ip:port通过 external profile 呼到指定 IP:Port
命名网关sofia/gateway/gwname/phone通过 FS 配置的命名 SIP 网关呼出
内部分机user/extension呼叫注册到 FS 的本地 SIP UA

关键变量

变量含义
origination_uuid预分配通道 UUID,Java 用此值跟踪通话
origination_caller_id_number主叫显示号码
absolute_codec_string强制编解码协商(PCMU/PCMA)
call_timeout呼叫超时秒数
ignore_early_media忽略早期媒体(180 带 SDP)

6.2 单腿呼出流程

Java → ESL originate {vars}sofia/external/phone@gateway 9999 XML public
         │
         ▼
FS → SIP 网关 → 运营商 → 用户手机响铃
         │
         ▼ 用户接听
FS 产生 CHANNEL_ANSWER 事件 → MQ 投递
         │
         ▼ Java 消费
ChannelAnswerHandler:
  ├── setWriteLevel(uuid, "4")              # 放音音量最大
  ├── uuid_audio_fork(uuid, wsUrl, 8000)    # 启动取音旁路
  └── greetingService.handleChannelAnswer() # 流式打招呼
         │
         ▼
通道进入 dialplan extension 9999 → 录音 + park
         │
         ▼ 产生 CHANNEL_PARK
进入正常事件处理链路,后续由 Java 通过 ESL 命令驱动

接通后动作9999 XML public 让通道接通后进入 dialplan 的 extension 9999(public context),在那里统一执行录音、park 等动作,然后产生 CHANNEL_PARK 事件进入正常处理链路。也可以直接用 &park()

6.3 双腿呼出流程

方式一:originate &bridge 一步到位

originate {vars}sofia/external/phone@gateway &bridge(user/8801)
Java → ESL originate {vars}sofia/external/phone@gateway &bridge(user/8801)
         │
         ▼
FS 先呼外线(A-leg):sofia/external/phone@gateway
         │
         ▼ 用户接听 → A-leg ANSWER
FS 执行 &bridge(user/8801) → 向 8801 分机发 INVITE(B-leg)
         │
         ▼ Java SipAgent 收到 INVITE
解析 SDP + 自定义头 → 200 OK + SDP Answer
         │
         ▼ A-leg 和 B-leg 在 FS 内部 RTP 直连
媒体桥接启动:FS(A) ←─RTP─→ FS(B) ←─RTP─→ Java ←─WS─→ 模型

方式二:originate + uuid_transfer 两阶段

# 阶段1:先呼外线,接通后 park
originate {vars}sofia/external/phone@gateway &park()

# 阶段2:Java 收到 CHANNEL_ANSWER 后,预连模型 + uuid_transfer
uuid_transfer {uuid} 8801 XML default
阶段1:originate ... &park()
  → 外线接通(A-leg) → CHANNEL_ANSWER → MQ → Java 消费
  → Java 创建 FsCallSession + 预连模型 WS(异步)
  → set sip_h_X-FS-Session-Id={sessionId}  # 注入会话标识
  → uuid_transfer 8801 XML default

阶段2:FS 向 Java 分机发 INVITE
  → Java handleOutboundTransferIncoming:
      → 从 X-FS-Session-Id 匹配预注册 session
      → 从 SDP 解析 FS 的 RTP 端点
      → session.initRtp(remoteIp, remotePort)  # 补全 RTP 信息
      → sipAgent.answerCall() → 200 OK + SDP Answer
      → session.start() → 启动音频桥接线程
      → onBridgeComplete → sendGreetingIfOutbound() → 发 SayHello
两阶段设计的工程原因:&bridge 模式下 FS 自动完成桥接,Java 无法在 bridge 前插入自定义逻辑(如预连模型 WS、注入会话标识)。而 Java 需要在收到 INVITE 时从 SDP 获取 RTP 端点,才能初始化 RtpReceiver/RtpSender。两阶段方案让 Java 在收到 INVITE 时有完整的控制权:阶段1 预连模型,阶段2 收到 INVITE 时补全 RTP 信息后应答。

6.4 呼出两种架构对比

维度单腿双腿
originate 接通后动作9999 XML public(进入 dialplan 后 park)&bridge(user/ext)&park() + uuid_transfer
接通事件获取CHANNEL_ANSWER(MQ)CHANNEL_ANSWER(MQ)+ SIP INVITE
媒体建立audio_fork 旁路B-leg RTP 直收
问候语触发Java 通过 ESL 播放 TTSJava 发 SayHello 事件给模型
会话标识传递channel variableSIP 自定义头(X-FS-Session-Id)

6.5 呼入 vs 呼出

维度呼入呼出
发起方FS 被动接收 INVITEJava 主动调用 originate
callerId 来源运营商在 INVITE From 头提供Java 在 originate 变量中设置
网关选择由运营商/中继决定入口Java 选择 SIP 网关
问候语时机用户打进后机器人先说话外线接通后机器人先说话
入口 dialplaninbound dialplan(destination_number 路由)originate 的接通后动作(&app 或 extension XML context)

7. 媒体路径对比

7.1 取音路径

维度单腿双腿
路径FS 内部 PCM → uuid_audio_fork 旁路复制 → WebSocket → ASRFS 把 A-leg RTP 转发到 B-leg → Java RtpReceiver → G.711 解码 → 重采样 → 模型 WS
媒体是否离开 FS否,fork 是只读旁路是,RTP 流出 FS 进入 Java
Java 感知 RTP
编解码FS 内部自动完成,Java 收到的是 PCMJava 自己做 G.711 解码

7.2 放音路径

维度单腿双腿
路径TTS 音频 → HTTP 接口 → FS 拉流 → uuid_broadcast 在通道内播放模型 WS 回包 PCM → Java G.711 编码 → RtpSender 发 RTP → FS(B→A) → 主叫
音频由谁产生FS 自行播放Java 构造 RTP 包发回 FS
放音方向可指定 aleg/bleg/both固定双向(bridge 后两腿都收到)

7.3 编解码责任

单腿双腿
编解码在 FS 内部完成。uuid_audio_fork 输出的已经是 PCM,uuid_broadcast 播放时 FS 自行处理格式转换。Java 只处理 PCM 文本/音频数据。Java 必须自己实现 G.711 编解码 + 采样率转换。FS 传过来的是 PCMU RTP 包,Java 需要解码为 PCM、重采样、送给模型;模型返回 PCM 需要重采样、编码为 PCMU、构造 RTP 包发回。

7.4 延迟构成

维度单腿双腿
AI 处理往返分层管道各层串行:ASR WS + LLM HTTP + TTS WS端到端模型单次 WS 往返
编解码开销FS 内部完成,不计入 Java 延迟Java 侧 G.711 编解码 + 重采样(毫秒级)
打断延迟ASR 首字 + ESL 命令往返 + FS 执行模型首字事件 + Java 本地清空队列

8. 控制平面对比

8.1 控制权归属

维度单腿双腿
媒体主导者FSJava
控制方式ESL 命令驱动SIP 信令
Java 角色外挂控制器对等 SIP UA

8.2 事件获取方式

事件类型单腿双腿
来电CHANNEL_PARK(MQ 消息)INVITE(SIP 请求)
接通CHANNEL_ANSWER(MQ 消息)200 OK(SIP 响应)
挂断CHANNEL_HANGUP(MQ 消息)BYE(SIP 请求)
DTMFDTMF 事件(MQ 消息)RFC 2833 RTP 事件(媒体流内携带)
基础设施需要 MQ(exchange + queue + consumer)不需要额外基础设施
事件种类丰富(CHANNEL_CREATE/ANSWER/BRIDGE/HANGUP/DTMF/PLAYBACK_START 等)SIP 层事件(INVITE/BYE/CANCEL)

8.3 状态机归属

单腿:双状态机双腿:单一状态机
FS 通道状态机 + Java 业务状态机,两者通过事件同步。需要处理状态不一致的情况(如 FS 通道已挂断但 Java 还在等 ASR 结果)。Java 完全掌控 B-leg 生命周期。通道的建立、媒体协商、挂断都由 Java 的 SIP 信令驱动,FS 的通道状态被动跟随。

8.4 上下文传递机制对比

维度channel variable(单腿)SIP 自定义头(双腿)
设置方式set var=xxxset sip_h_X-Field=xxx
可见范围仅 FS 内部(事件、ESL 命令)SIP 信令层,可穿越 SIP 网络
传递距离不离开 FS随 INVITE 传到 B-leg 终端
Java 读取从 MQ 事件 headers 读 variable_xxx从 SIP INVITE 头读 X-Field
是否污染 SIP 信令是(占用 SIP 头空间)

9. 并发与故障域

9.1 并发模型

维度单腿双腿
FS 侧单进程承载所有通道,媒体在内核级转发单进程承载所有通道,A/B 两腿 RTP 直连
Java 侧并发隔离MQ 分片或哈希线程池(UUID.hashCode() % N),同 UUID 事件保序每会话独立线程组,ConcurrentHashMap 隔离
事件顺序保证consistent-hash exchange 按 UUID 分片到固定队列SIP 事务层天然有序

9.2 故障域

单腿:分层隔离双腿:耦合故障
AI 管道分层,某层故障可独立降级(如 LLM 超时切 backup 厂商、TTS 降级到备用音色)。媒体腿不受 AI 管道故障影响——ASR 断了通道还在,TTS 挂了可以换一家重试。Java 进程是 B-leg 终端,Java 挂掉则 B-leg 断,FS 检测到 RTP 断流后挂断 A-leg。端到端模型断连同样波及整条腿——模型 WS 断开,Java 无法产生出向 RTP,B-leg 无媒体流,通话终止。

9.3 资源消耗

维度单腿双腿
Java CPU不处理 RTP,CPU 消耗集中在 AI 管道的网络 IO处理每路通话的 RTP 编解码 + 重采样,CPU 与并发路数线性相关
网络带宽ESL 命令是短文本协议,带宽开销小RTP 媒体流带宽 + 模型 WS 音频流
基础设施需要 MQ(exchange + queue + consumer)不需要额外 MQ

10. 媒体工程要点

10.1 采样率转换(双腿独有)

FS 电话侧固定 8kHz PCMU,端到端模型通常要求 16kHz 输入 / 24kHz 输出。Java 必须在两个方向上做采样率转换。

  • 上采样(8k → 16k):相对简单,线性插值即可。
  • 下采样(24k → 8k):需要先低通滤波抗混叠再抽取。FIR 滤波器必须有状态——保存跨帧的历史样本,避免帧边界零填充导致的音频不连续("弹簧音")。

10.2 静音保活(双腿独有)

RTP 协议中,通话静音期间如果不发包,对端可能误判为媒体断开。FS 在一定时间内收不到 B-leg 的 RTP 包,可能触发媒体超时挂断。

解决方案:静音期间持续发送 RTP 静音包(PCMU 静音码为 0xFF),维持媒体流活跃。

10.3 噪声门控(双腿独有)

Java 直接收 RTP,收到的 PCM 数据包含背景噪声。双阈值噪声门控是常见工程方案:信号超过高阈值时开门(开始发送),低于低阈值时关门(停止发送),门控开关时加淡入淡出避免咔嗒声。

10.4 双向挂断防重入(双腿独有)

双腿架构中,挂断可能从 A-leg 侧(用户挂机 → FS 发 BYE → Java)或模型侧(模型 WS 断连 → Java 需要通过 ESL 让 FS 挂断 A-leg)发起。两条路径可能同时触发,需要用 CAS(compareAndSet)防止重复清理资源。

10.5 放音方向控制(单腿独有)

单腿 park 状态下,uuid_broadcast 可指定 aleg / bleg / both 方向。在转人工场景(临时建立 B-leg 桥接到坐席)中用于控制 AI 只对一侧放音,不让另一侧听到。

10.6 对称 RTP(双腿硬约束)

收发必须共用同一个 UDP socket。这是 RTP 协议的要求——对端根据收包源地址回复媒体,如果收发端口不同,对端无法正确回复。Java 实现中 RtpSender 必须从 RtpReceiver 获取共享 socket。

11. 总结

单腿与双腿是 FreeSWITCH 媒体腿数量的两种选择,对应控制平面与媒体平面的不同分工:

单腿(park + ESL 旁路)双腿(transfer + SIP UA)
  • 媒体留在 FS,Java 是 ESL 控制器
  • AI 是分层管道,每层可独立观测和降级
  • 上下文通过 channel variable 传递(不离开 FS)
  • 事件通过 MQ 获取,种类丰富
  • 故障域分层隔离
  • 媒体交到 Java,Java 是 B-leg 终端 + 模型网关
  • AI 是端到端模型,单次 WS 往返
  • 上下文通过 SIP 自定义头传递(穿越 SIP 网络)
  • 事件通过 SIP 信令获取,无需 MQ
  • 故障域耦合,Java/模型挂掉波及整腿

腿数是技术架构的第一性选择,决定了媒体路径、控制方式、编解码边界、故障隔离粒度的根本差异。理解这个差异,是做语音机器人架构选型的前提。

呼入与呼出的统一性:无论呼入还是呼出,两种架构的媒体腿结构、控制平面、编解码责任都是一致的。差异只在于发起方式(被动接收 INVITE vs 主动调用 originate)和上下文传递时机的细节。腿数选择与呼入/呼出场景正交。
相关新闻
热点新闻
精彩视频
投票
查看结果
Tags

站点地图 在线访客: 今日访问量: 昨日访问量: 总访问量:

© 2026 北京千阶智能科技有限公司 版权所有

公安备案 京公网安备11010802049169号 京ICP备2026018665号-1