XSwitch

XCC API手册

xswitch.cn

2022年4月18日

法律声明

若接收烟台小樱桃网络科技有限公司(以下称为“小樱桃”)的此份文档,即表示您已同意以下条款。若不同意以下条款,请立即停止使用本文档。

本文档版权所有烟台小樱桃网络科技有限公司。保留任何未在本文档中明示授予的权利。文档中涉及小樱桃的专有信息。未经小樱桃事先书面许可,任何单位和个人不得复制、传递、分发、使用和泄漏该文档以及该文档包含的任何图片、表格、数据及其他信息。本网页版文档仅在xswitch.cn上发布。

本产品符合有关环境保护和人身安全方面的设计要求,产品的存放、使用和弃置应遵照产品手册、相关合同或相关国法律、法规的要求进行。

本文档按“现状”和“仅此状态”提供。本文档中的信息随着小樱桃的产品和技术的进步将不断更新,小樱桃不再通知此类信息的更新。

烟台小樱桃网络科技有限公司

地址:烟台市高新区蓝海路1号
邮编:264000
电话:0535-6753997

1 综述

XSwitch是一个电信级的IP电话软交换系统和综合实时音视频多媒体通信平台

本文档描述XSwitch XCC API的设计原则和使用方法。XCC是XSwitch Call Control(XSwitch呼叫控制)的缩写。

XSwitch支持电话、传真、视频会议、呼叫中心等。支持主流的通信协议如SIP、H323、WebRTC、RTMP等,支持单机部署、云原生集群部署,支持无限扩容及动态伸缩。目标是为用户提供一站式语音、视频、会议解决方案,可以作为IP-PBX、视频会议服务器、传真服务器、多协议网关、呼叫中心服务器等使用。XSwitch提供REST、Websocket二次开发接口。XSwitch基于开源技术构建,如FreeSWITCH、PostgreSQL、Nginx等。

XSwitch是模块化,积木式按需叠加和无限伸缩扩容的通信产品,更可以通过定制支持集群部署,实现更强大的功能。

XSwitch的位置、组件和逻辑关系如下图所示:

其中,SIP话机通过SIP协议接入XSwitch,管理员可以通过Web浏览器进行系统管理和维护,也可以通过WebRTC打电话,静态Web页面都由Web服务器提供,动态API请求会由Web服务器转发到XSwitch端进行处理。

XCC相关的呼叫控制架构如下图所示:

其中,XSwitch是核心交换节点,负责SIP信令和媒体交换。当SIP客户端(或通过运营商IMS线路)有呼入时,XSwitch会通过消息队列通知控制器,控制器进而可以接管整个通话流程。控制器只是发送控制指令和消息,实际的音视频媒体都在XSwitch内处理。

有时候,为了定义更明确,XSwitch又称为XNode,即交换节点。消息队列使用NATS实现,控制器简称XCtrl,因而也可以以下图方式表示,它其实与上图是等价的。

1.1 导言

XSwitch底层基于开源的FreeSWITCH开发,FreeSWITCH作为XSwitch的核心交换节点,即XNode。FreeSWITCH性能强悍,但应对大规模应用时还是需要组建集群。因此我们设计了一个集群架构。

集群可以运行在裸金属服务器或虚拟机上,但更理想的环境是运行在K8S上,便于更方便地进行动态伸缩。FreeSWITCH节点可以随时加入和退出服务,即所谓的动态伸缩。

系统中,所有组件都可以多个实例工作,多个组件可以分布在多个物理节点上,一个物理节点发生故障不影响整个集群运行。

通过消息队将通话和控制解耦。

1.2 设计理念

FreeSWITCH博大精深,用好FreeSWITCH需要对它充分了解,不仅仅是底层的SIP通信机制会让一些非通信专业的程序员头痛,FreeSWITCH的通话控制逻辑也让很多人感觉晕头转向。考虑以下问题:

FreeSWITCH是一个B2BUA,当A发起一个呼叫时,FreeSWITCH会创建一个Channel,然后在内部会执行一个Application跟对端进行交互,比如playback就是直接播放一个声音,这个比较容易理解。当A想跟B通话时,FreeSWITCH又会发起一个呼叫,然后将两路呼叫桥接起来(称为bridge)。更多方的呼叫可以用会议(conference)方式实现。

那么,FreeSWITCH怎么知道在什么情况下执行哪些Application呢?很简单,你要告诉它。这就需要你预先制定一定的规则(我们称为dialplan,即拨号计划),FreeSWITCH默认使用XML的拨号计划。

规则是死的,如果你希望更灵活的规则,你就可以用Lua脚本,或者ESL接口在外部进行控制。

问题是,通话逻辑是很复杂的,在通话中你不仅希望灵活地放音,还希望有转接、录音、收DTMF、ASR语音识别、甚至发送特定的SIP消息等,这些都需要很灵活的控制,但不幸的是,这很复杂。

我们再来思考一个更一般的A与B互通的逻辑:A呼B,如果有一方挂机,对方就会自动挂掉,这在电话交换专业中叫做“互不控制”。但是,实际的场景却又有不同的需求,比如在呼叫中心场景中,如果座席挂机,那么要引导客户对座席的服务进行评价,就需要客户侧不能自动挂机……

是的,FreeSWITCH提供了很多实现方法,比如通过Application与拨号计划的有机组合,但什么时候需要转拨号计划?什么时候Application就可以?有什么副作用?要达到完全理解和领会并用得出神入化,需要很长时间。

本文档描述的接口就是致力于解决这些问题——让控制FreeSWITCH程序员不用纠结于FreeSWITCH的内部逻辑,从更高的视角分析业务需求,进而调用合适的API进行控制。

我们深知,看到本文档的程序员都是深资的程序员,大家熟悉各种数据结构和交互逻辑,只是不熟悉FreeSWITCH的交互和各种SIP控制。把专业的事情交给专业的人做,我们做好底层通信能力,您就可以省出时间更多的关注自己的业务逻辑。

通过消息队列将控制和媒体分离,最大程度地减少耦合,是我们设计的初衷。

值得一得的是,单独的这些XCC接口并不能完全让你无视FreeSWITCH,但至少可以让你大部分无视。我们在XCC接口的基础上又做了一个更高级的呼叫中心服务和业务逻辑,就可以真正做到“与FreeSWITCH无关”了。

1.3 名词术语

  • XSwitch:XSwitch软交换平台。
  • XNode:XSwitch交换节点,负责SIP信令和媒体交换,可以有多个。有时也简称Node。
  • XCtrl:XController,即XSwitch控制器。用于控制呼叫流程,用户自己实现,可以用任何语言编写。有时也简称Ctrl。
  • XCC:XSwitch Call Control,即XSwitch呼叫控制。
  • NATS:一个消息队列。
  • Proxy:代理服务器。SIP Proxy用于SIP用户注册,通话负载分担等。HTTP Proxy用于HTTP以及Websocket负载分担。
  • IMS:IP Multimedia Subsystem,即IP多媒体子系统,运营商的系统。

2 设计架构及交互流程

系统整体控制架构和弹性伸缩基于NATS消息队列实现。

2.1 NATS简介

NATS1是一个消息队列,它实现了Pub/Sub(生产者-消费者)消息机制,通过它也可以实现“请求-响应”式的RPC(Remote Procedure Call,远程过程调用)交互。

2.1.1 Subject

NATS使用主题(Subject)对消息进行标记,实际消息内容可以是任何文本或二进制消息。XSwitch使用JSON-RPC消息封装。

在使用NATS前,需要了解NATS的核心概念,参考文档:https://docs.nats.io/nats-concepts/core-nats。文档都是英文的,下面是简要的中文解释。

2.1.2 Pub/Sub

在实际使用时,XNode和Ctrl分别向NATS服务器订阅自己关心的主题,然后就可以互相发送消息了。消息本身是异步的,也就是说,生产者产生一条消息,发送出去就完了,而消费者可以有0个或多个,订阅同一个主题的消费者都能收到这条消息,如果没有消费者,消息就会被丢弃。

参考文档:https://docs.nats.io/nats-concepts/core-nats/pubsub

2.1.3 Request/Reply

通过订阅一个一次性的主题,可以实现阻塞的“请求-响应”调用。这个一次性的主题称为“邮箱”(Mailbox),如下图:

其中,XNode发出一条消息前订阅了一个邮箱,Ctrl收到这条消息后往这个邮箱里回复了一条响应消息。

当然,这个一次性的主题也可以扩展为N(N>1)的场景,在此不做讨论。

参考文档:https://docs.nats.io/nats-concepts/core-nats/reqreply

2.1.4 Queue Groups

NATS通过队列组可以执行类似负载均衡的分发策略。对于多个消费者来说,订阅了相同Queue的一组消费者中,只有一个可以收到消息,也就是说消息分发是互斥的。如下图,控制器1、2、3以同一个队列(queue-1)方式订阅了同一个Subject,只有一个能收到消息,而控制器4没有以队列方式订阅,控制器5使用了另一个队列(queue-2),它们也能收到消息。

通过这种方式,可以实现Round Robin(轮循)方式的消息分发。比如同时来了300路通话,每个Ctrl可以处理100路。

参考文档:https://docs.nats.io/nats-concepts/core-nats/queue

2.1.5 集群

NATS支持集群,多个NATS间也可以自由转发消息。如果连接到其中一个NATS服务器失败,NATS客户端库会自动尝试连接下一个NATS服务器。

基于NATS实现的XSwitch集群将在后面的章节讨论。

2.1.6 SDK

NATS有各种语言的客户端SDK,因此使用起来很方便,具体的例子见后文。

2.2 JSON-RPC

远程过程调用使用JSON-RPC 2.0格式定义。下面是一个JSON-RPC请求:

{
                            "jsonrpc": "2.0",
                            "id": "1",
                            "method": "XNode.Answer",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid ... ",
                                "uuid": "..."
                            }
                        }

JSON-RPC中,所有请求都有一个id,代表期望返回一个结果。且返回结果中的id与请求中的id一致。JSON-RPC规定id可以是null、数字或字符串,但为了简单起见,我们只使用字符串。

id可以为不存在(注意不是nullnull是一个值),如果id不存在,则认为是一个事件(或称通知,Notification),事件其实也是一个请求,但是不需要对方返回结果。

如果返回结果中有result,则为正常返回,否则,应该返回error。两者都可以是任何合法的JSON对像或值。

{
                            "jsonrpc": "2.0",
                            "id": "...",
                            "result": {
                                ...
                            }
                        }
                        
                        {
                            "jsonrpc": "2.0",
                            "id": "...",
                            "error": {
                                ...
                            }
                        }

详细的JSONRPC规则可参考:

  • https://www.jsonrpc.org/specification
  • http://wiki.geekdream.com/Specification/json-rpc_2.0.html

2.3 XCC API

通过NATS使用JSON-RPC进行远程过程调用的API称为XCC API。

JSON-RPC相当于一个信封。在信封内部,是XCC API具体请求的内容,统一放到params中。信封中的出错信息通常是因为信封出错导致的,如必选参数不存在、方法不存在等。如果信封合法,则具体的返回结果都放到result中,返回结果中均有如下参数:

  • code:代码,参照HTTP代码规范,如2xx代表成功,4xx代表客户端错误,5xx代表服务端错误等。
  • message:对代码的解释。有一些message中会带有一些00开头的错误码,这些错误码没有任何含义,仅为方便追踪错误而设。
  • uuid:如果请求中有uuid,则结果中也有uuid,代表当前的Channel UUID。

通用code返回值说明

  • 100:临时响应,实际的响应消息将在后续以异步的方式返回。
  • 200:成功。
  • 202:请求已成功收到,但尚不知道结果,后续的结果将以事件(NOTIFY)的形式发出。
  • 206:成功,但是数据不全,如发生在放音过程中通过API暂停的情况。
  • 400:客户端错误,多发生在参数不全或不合法的情况。
  • 410:Gone。发生在放音或ASR检测过程中用户侧挂机的情况。
  • 419:冲突。如一个电话被接管,另一个控制器又尝试接管的情况。
  • 500:内部错误。
  • 6xx:系统错误,如发生在关机或即将关机的情况下,拒绝呼叫。

XCC API即控制指令。控制指令分类两类:

  • Channel API:作用于一个Channel,必须有一个uuid参数,uuid即为当前Channel的uuid
  • 普通API:普通API没有uuid参数。如对FreeSWITCH的控制,发起外呼等。

API在XSwitch后台使用多线程调度,所有API在前面看起来都是阻塞的,但由于消息队列的异步特性,可以被同步或异步地调用。

2.4 Session与Channel

在XSwitch中,Session与Channel通用,代表一路呼叫。XSwitch是个B2BUA,如果是单路的IVR,则是一个Channel,在a呼b的场景中,是两个Channel。每个Channel在XSwitch中也称为一条“腿”(Leg),呼入的腿一般称为a-leg,呼出的腿称为b-leg

所有的XCC API都是针对Channel进行操作(通过uuid参数),如Play、TTS等。有的API(如ChannelBridge)可以同时操作两条腿,甚至更多的腿。

2.5 来话处理

XSwitch侧所有来话均会被置于park(暂停、挂起)状态,然后XSwitch广播一个Event.Channelstate=START)事件到消息队列。关注该消息队列的控制服务可以接管这个呼叫。

控制服务称为XCtrl。多个XCtrl可以协同工作。第一个发送接管指令(Accept)并接管成功的XCtrl将接管该呼叫。后续XSwitch内所有跟当前通话相关的事件都会发到这个节点上。

如果一个来话在10秒内没有被接管,则呼叫将会被挂断。

如果想对来话直接应答,则可以直接使用Answer应答,Answer会隐含进行Accept。流程图如下:

2.6 去话处理

去话,即外呼,使用Dial方法实现,由于外呼可能持续比较长的时间,外呼在XSwitch侧是在单独的线程中处理的,所以外呼请求会收到code = 202消息表示该外呼请求已被接受正在排队。如果外呼不成功,或者有成功进展,都会有后续的Event.ChannelEvent.Result消息。

简单外呼的流程图如下:

2.7 Channel State

Channel状态。

2.7.1 来话Channel状态

  • START:来话第一个事件,XCtrl应该从该事件开始处理,第一个指令必须是Accept或Answer
  • RINGING:振铃
  • ANSWERED:应答
  • BRIDGE:桥接
  • UNBRIDGE: 断开桥接
  • DESTROY:挂机

2.7.2 去话Channel状态

  • CALLING:去话第一个事件
  • RINGING:振铃
  • ANSWERED:应答
  • MEDIA:媒体建立
  • BRIDGE:桥接
  • READY:就绪
  • UNBRIDGE: 断开桥接
  • DESTROY: 挂机

在调用XNode.Dial外呼的时候,在ignore_early_media=false(默认)的情况下,收到MEDIA就会触发READY事件。如果为true,则需要等到ANSWERED以后才会触发READY状态。不管什么情况,都需要在收到READY状态后才可以对Channel进行控制。

在执行XNode.Bridge时,没有READY事件,这时可以根据ANSWEREDBRIDGE事件处理业务逻辑。

在XNode中,一个Channel从创建开始(state = STARTstate = CALLING),到销毁(state = DESTROY),是一个完整的生命周期。销毁前,会发送Event.CDR事件,通常会在单独的Topic上发出(可配置)。

一般来说,只要Channel被创建,总会有对应的DESTROY消息。但是,在XNode发生崩溃的情况下,需要准备超时垃圾回收机制。

2.8 同步调用

使用NATS客户端库的request函数发起同步调用。同步调用在Ctrl侧会阻塞直到返回结果。同步调用有一个超时参数timeout,如果发生超时,不一定代表出错。考虑以下场景,播放一个声音文件(file):

request(play, file, timeout = 10)

如果文件长度为5秒,则该函数会在5秒播放完成后返回。

如果文件长度为15秒,则该函数会在10秒后超时。但文件的播放仍在进行。客户端可以选择继续等待播放完成的事件(Event.PLAYBACK_STOP),或停止当前播放(调用Stop方法)。

注意:在实际开发时,要尽量避免出现NATS timeout,也就是说要让timeout足够长,因为在出现timeout的情况下(如上述场景),实际上文件可能没有播放完成,也可能是网络断了或XSwitch崩溃了等各种原因,因而,Channel的状态在Ctrl侧是是未知的,这就需要很小心的错误处理以应对各种情况(如,可以主动查询一下Channel状态,但查询可能又会发生超时……)。

2.8.1 expect_100_trying

为了方便在同步调用时快速知道执行结果,在同步调用中有一个实验性的参数params.exepect_100_trying。使用该参数时必须提供params.ctrl_uuid。如果expect_100_trying = true,则调用会立即返回(code = 100或错误消息),如果返回的是错误消息,则错误消息为最终响应,不会有后续结果。如果返回的是100,则实际的调用结果将发送到异步订阅的Controller主题上,该主题规则为ctrl_uuid的拼接,如cn.xswitch.ctrl.$ctrl_uuid。如果ctrl_uuid不存在,则会发送到AcceptAnswer接管的Controller指定的位置。

目前支持该操作的接口有:

  • Bridge
  • ChannelBridge
  • Play
  • Record(仅同步模式(action = RECORD)时可用)
  • ReadDTMF
  • DetectSpeech
  • RingBackDetection

2.9 异步调用

使用NATS的publish函数可以发起异步调用。异步调用时,如果rpc.id为空,表示Ctrl不需要看到结果,XSwitch不会发送API的执行结果消息,但是有可能产生相关的事件,如(PLAYBACK_STOP)。

如果异步调用时rpc.id为非空,则FreeSWITCH会返回Event.Result消息,Ctrl应该订阅该消息(Subject为cn.xswitch.ctrl.<ctrl_uuid>),以获取执行结果。结果中的rpc.id与请求时中的rpc.id一致。

2.10 Channel变量

每一个Channel上都有很多参数(param),也叫变量(variable),如caller_id_namecaller_id_number等。这些变量通常在Event.Channel事件中发送。对于每一路通话,XSwitch Channel上会有数百个相关的变量,然而,大多数时候我们并不需要所有变量,因而,为了简洁起见,XSwitch并不发送所有变量,而是只发送很少的变量。

如果在开发应用中有特殊需求,可以在Controller侧用以下方式“订阅”更多变量:

  • xcc.conf中静态配置,这个配置是全局的,即所有Channel事件都会带这些变量
  • 对于入局呼叫,可以在AcceptAnswer时,通过channel_params字符串数组动态订阅变量
  • 对于出局呼叫,可以在DialBridgeDestination参数中通过channel_params数组动态订阅变量
  • 在呼叫过程中,可以通过SetVar中的channel_params数组动态订阅变量。

订阅的变量会在Channel生命期内一直有效,xcc.conf中静态配置的变量会与动态订阅的变量合并(去重)发送。Event.Channel事件中,会在params.params参数中携带,参数和值全部都是字符串。

订阅的变量也会体现在Event.CDR中,但会与Event.CDR中的变量混在一起,没有二级params.params

变量列表
Info/Event显示名称 通道变量名称 说明
Channel-State state 当前Channel的状态
Channel-State-Number state_number Channel状态整数值
Channel-Name channel_name Channel名称
Unique-ID uuid Channel UUID,唯一标志
Call-Direction direction 呼叫方向,inbound/outbound
Answer-State state 应答状态
Channel-Read-Codec-Name read_codec 接收语音编码
Channel-Read-Codec-Rate read_rate 接收采样率
Channel-Write-Codec-Name write_codec 发送语音编码
Channel-Write-Codec-Rate write_rate 发送采样率
Caller-Username username 鉴权用户名
Caller-Dialplan dialplan Dialplan名称,如XML、Lua等
Caller-Caller-ID-Name caller_id_name 主叫名称
Caller-Caller-ID-Number caller_id_number 主叫号码
Caller-ANI ani ANI,一般与主叫号码相同
Caller-ANI-II aniii ANI II 2
Caller-Network-Addr network_addr 主叫IP地址
Caller-Destination-Number destination_number 被叫号码
Caller-Unique-ID uuid Channel UUID
Caller-Source source 源模块,如mod_sofia
Caller-Context context Dialplan Context
Caller-RDNIS rdnis 转移后的DNIS信息,见transfer Application
Caller-Channel-Name channel_name
Caller-Profile-Index profile_index .
Caller-Channel-Created-Time created_time 创建时间
Caller-Channel-Answered-Time answered_time 应答时间
Caller-Channel-Hangup-Time hangup_time 挂机时间
Caller-Channel-Transfer-Time transfer_time 转移时间

详细的通道变量参见以下链接:

https://freeswitch.org/confluence/display/FREESWITCH/Channel+Variables

3 快速上手

接下来,我们看几个实际的例子,以便能快速理解和使用XCC API控制呼叫。

本章的示例大部分以Node.js为例,因为它基于Javascript语言,易懂,也容易讲解。

3.1 接听后播放文件

在XSwitch中创建如下路由:

  • 被叫号码:1234
  • 目的地类型选择:系统
  • 目的地:xcc

当呼叫1234时,路由到xcc,它会固定往cn.xswitch.ctrl上发Event.Channel消息。如果想让消息发到特定的Subject上,目的地中可以填xcc cn.xswitch.ctrl.你自己的Ctroller的UUID

呼叫流程看代码中的注释(简单起见没有加太多错误处理代码)。本示例使用了NATS Node.js V2 SDK,使用了xcc.js简单封装。具体代码可以参见本文档尾部相关链接。

'use strict';
                        
                        const XCC = require('./xcc')
                        let nats_url = process.env.NATS_URL;
                        let service = process.env.XSWITCH_SUBJECT || 'cn.xswitch.node.test';
                        let xctrl_subject = process.env.XCTRL_SUBJECT || 'cn.xswitch.ctrl';
                        
                        const options = {
                            nats_url: nats_url,
                            log_level: 7,
                            service: service,
                        }
                        
                        const xcc = new XCC(options); // 新建一个对象
                        
                        xcc.on('connected', () => {
                            console.log("connected"); // 连接后打印日志
                        });
                        
                        xcc.on('START', (call) => {
                            ivr(call); // 来话处理
                        });
                        
                        (async () => {
                            await xcc.init(); // 连接到NATS
                            xcc.listen(xctrl_subject, 'play'); // 等待呼入
                        })();
                        
                        async function ivr(call) { //呼叫处理
                            console.log(`>> call from ${call.cid_number} to ${call.dest_number} uuid=${call.uuid} Total Calls: ${xcc.ncalls()}`);
                            call.on('DESTROY', (params) => {
                                // xcc.log('>> DESTROY', params);
                                xcc.log('>> DESTROY', params.uuid, `Total Calls: ${xcc.ncalls()}`);
                            });
                            await call.answer(); // 应答
                            // await call.play('/tmp/test.wav'); // 本地wav文件
                            await call.play('https://xswitch.cn/download/wav/xiaoyingtao.wav'); // http文件
                            await call.play('silence_stream://1000'); // 1秒静音
                            await call.play('silence_stream://2000'); // 2秒
                            await call.play('silence_stream://3000'); // 3秒
                            await call.hangup('NORMAL_CLEARING');     // 挂机
                            console.log(`Done uuid=${call.uuid} Total Calls: ${xcc.ncalls()}`);
                        }

如果要播放TTS,则需要设置TTS引擎,代码如下:

    await call.answer();
                            call.set_tts_params('ali', 'default'); // 设置TTS引擎和语法
                            await call.speak('您好');

V1版的SDK看起来就比较长了,但也比较直观,简要注释如下,供参考:

const NATS = require('nats')          // 引入NATS
                        let nats_url = process.env.NATS_URL;  // 从环境变量中获取NATS服务器地址
                        // 连接NATS服务器,默认为`nats://localhost://4222`
                        const nc = NATS.connect(nats_url);
                        const tts_engine = "ali"              // 设置TTS引擎,相关模块必须存在
                        const uuidv4 = require('uuid/v4');    // UUID库
                        const ctrl_uuid = uuidv4();           // 随机生成一个UUID,作为本控制器的UUID
                        
                        console.log("IVR started, waiting ...")
                        
                        function default_rpc()
                        {
                            var rpc = {                       // 构造JSON-RPC对象
                                jsonrpc: "2.0",
                                method: "XNode.Answer",
                                id: "fake-uuid-answer",
                                params: {
                                    ctrl_uuid: ctrl_uuid
                                }
                            }
                        
                            return rpc
                        }
                        
                        // 应答函数,service为XNode侧的Subject,uuid为当前Channel的UUID
                        function xnode_answer(service, uuid, callback)
                        {
                            // 设置应答请求的参数
                            var rpc = default_rpc();
                            rpc.method = "XNode.Answer";
                            rpc.params.uuid = uuid
                        
                            // 转为JSON字符串
                            const msg = JSON.stringify(rpc);
                            console.log("sending " + msg);
                            // 发送请求,超时时间为1000毫秒,然后会执行callback函数
                            nc.request(service, msg, { max: 1, timeout: 1000 }, callback);
                        }
                        
                        // 播放函数,uuid为当前Channel的UUID,file为语音文件名
                        function xnode_play(service, uuid, file, callback)
                        {
                            var rpc = default_rpc();
                            rpc.id = 'fake-play';
                            rpc.method = "XNode.Play";
                            rpc.params.uuid = uuid;
                            rpc.params.media = {
                                data: file,
                            }
                        
                            const msg = JSON.stringify(rpc);
                            console.log("sending " + msg);
                            // 发送请求,简单起见,超时时间硬编码为20秒,可自行调整
                            nc.request(service, msg, { max: 1, timeout: 20000 }, callback);
                        }
                        
                        // 订阅本Ctrl的Subject,当收到消息时打印相关消息
                        nc.subscribe('cn.xswitch.ctrl.' + ctrl_uuid, function (msg, reply, subject, sid) {
                            console.log('Received a message: ' + subject + ' ' + msg)
                        })
                        
                        // 订阅消息,等待呼入,电话呼入后的第一个消息会发到这里
                        nc.subscribe('cn.xswitch.ctrl', {queue: 'controller'}, function (msg, reply, subject, sid) {
                            console.log('Received a message: ' + subject + ' ' + msg)
                            // 收到的是一个字符串,转换成Javascript对象
                            m = JSON.parse(msg);
                        
                            // 简单的合法性检查
                            if (m.method) {
                                if (m.method == 'Event.Channel') {
                                    if (m.params.state == 'START') {  // 第一个消息的state = START
                                        // 注意,这个消息中没有`id`,也就是说这只是一个事件通知
                                        console.log('new call from ' + m.params.cid_number) // 打印主叫号码
                                        const channel_uuid = m.params.uuid;  // 这是当前Channel的UUID
                                        // node_uuid是XNode侧的UUID,拼成一个Subject,XNode侧应该已经订阅了该Subject
                                        const service = 'cn.xswitch.node.' + m.params.node_uuid;
                        
                                        // 应答,它将会发送一个应答指令,这是一个JSON-RPC请求消息
                                        xnode_answer(service, channel_uuid, (msg) => { // 应答请求的回调函数
                                            if (msg instanceof NATS.NatsError && msg.code === NATS.REQ_TIMEOUT) {
                                                console.log('request timed out');
                                            } else {
                                                console.log('Got a response: ' + msg);
                                                m = JSON.parse(msg);
                                                if (m.result.code == 200) { // 200代表请求成功
                                                    var file = '/tmp/welcome.wav'; // 播放文件
                                                    xnode_play(service, channel_uuid, file, (msg) => {
                                                        if (msg instanceof NATS.NatsError && msg.code === NATS.REQ_TIMEOUT) {
                                                            console.log('request timed out');
                                                        } else {
                                                            console.log('Got a response: ' + msg);
                                                        }
                                                    });
                                                }
                                            }
                                        });
                                    }
                                }
                            }
                        })
                        
                        // 永久等待,防止退出
                        function wait() {
                            // console.log('tick ... ');
                            setTimeout(wait, 3000);
                        }
                        
                        wait();

Node.js实际上只有一个线程,所有请求的结果都使用回调函数来实现。Async/Await的版本可以以同步的方式写异步代码,代码看起来更简单,但其实只是个语法糖,学起来也是有门槛的,需要懂Promise。

3.2 外呼

外呼代码片断如下。

let auto_play = false; // 是否自动播放,见后面
                        // 设置呼叫字符串,该示例号码会自动接听
                        const dial_string = 'sofia/public/10000200@rts.xswitch.cn:20003;transport=tcp';
                        
                        function default_rpc() {
                            var rpc = {
                                jsonrpc: "2.0",
                                method: "XNode.Answer",
                                id: "fake-uuid-answer",
                                params: {
                                    // 简单起见使用硬编码,实际使用时应该保证与其它Controller不重复,最好是个真正的UUID
                                    ctrl_uuid: 'test-nodejs-controller'
                                }
                            }
                        
                            return rpc
                        }
                        
                        // 播放
                        function xnode_play(service, uuid, file, callback) {
                            var rpc = default_rpc();
                            rpc.method = "XNode.Play";
                            rpc.id = uuid
                            rpc.params.uuid = uuid
                            rpc.params.media = {
                                data: file
                            }
                        
                            const msg = JSON.stringify(rpc);
                            console.log("sending " + msg);
                            nc.request(service, msg, { max: 1, timeout: 5000 }, callback);
                        }
                        
                        // 挂机
                        function xnode_hangup(service, uuid, callback) {
                            var rpc = default_rpc();
                            rpc.id = 'fake-uuid-hangup'
                            rpc.method = "XNode.Hangup";
                            rpc.params.uuid = uuid
                        
                            const msg = JSON.stringify(rpc);
                            console.log("sending " + msg);
                            nc.request(service, msg, { max: 1, timeout: 1000 }, callback);
                        }
                        
                        console.log("caller started, waiting ...");
                        
                        // controller handler,与该Controller相关的消息都会发到这里
                        nc.subscribe('cn.xswitch.ctrl.test-nodejs-controller', function (msg, reply, subject, sid) {
                            console.log('Received a message: ' + subject + ' ' + msg)
                            m = JSON.parse(msg);
                        
                            if (m && m.params) {
                                if (m.params.state == 'DESTROY') {
                                    running = false;
                                } else if (m.params.state == 'READY') { // 呼叫成功后,收到的第一个消息
                                    if (auto_play) { // 如果使用auto_play,这里我们什么也不需要做
                                        // auto_play mode, just waiting for hangup
                                        console.log("call is auto playing, waitinng to hangup");
                                        return;
                                    }
                        
                                    // 根据node_uuid构造XNode侧的Subject
                                    var service = 'cn.xswitch.node.' + m.params.node_uuid;
                                    const channel_uuid = m.params.uuid;
                                    var file = 'silence_stream://3000';
                                    xnode_play(service, channel_uuid, file, (msg) => { // 播放文件
                                        if (msg instanceof NATS.NatsError && msg.code === NATS.REQ_TIMEOUT) {
                                            console.log('request timed out');
                                        } else {
                                            console.log('Got a response: ' + msg);
                                        }
                                    })
                                }
                            }
                        })
                        
                        // 每一路电话都有一个UUID,这里我们事先构造一个
                        const uuid = uuidv4()
                        
                        console.log('calling ' + uuid + ' ...')
                        
                        var rpc = default_rpc();
                        rpc.method = "XNode.Dial"  // 外呼
                        rpc.id = 'call1'; // 请求id,实际使用时应该使用不重复的id以方便跟踪
                        rpc.params.destination = {
                            global_params: {
                                ignore_early_media: "true" // 忽略早期媒体(如回铃音、彩铃等)
                            },
                            call_params: [{  // 这是个数组,数组中的每一个对象代码一路,可以并行呼叫,这里我们只呼一路
                                uuid: uuid,  // Channel UUID
                                dial_string: dial_string, // 呼叫字符串
                                params: {    // 呼叫参数
                                    leg_timeout: "20",  // 无应答超时
                                    // absolute_codec_string: "G729",  // 使用何种编解码
                                }
                            }]
                        }
                        
                        if (auto_play) { // 在auto_play的情况下,呼通后会自动执行这些app,而无须本侧再控制,比较简单
                            rpc.params.apps = [
                                // 播放一次铃声
                                {app: "playback", data: "tone_stream://%(100,1000,800);loops=1"},
                                // 播放三次铃声
                                {app: "loop_playback", data: "+3 tone_stream://%(100,1000,800);loops=1"},
                                // 挂机
                                {app: "hangup", data: "NORMAL_CLEARING"}
                            ];
                        }
                        
                        // XNode侧订阅的Subject
                        service = "cn.xswitch.node.test"
                        
                        // 发送呼叫请求,呼叫进展消息(振铃、应答等)将可在cn.xswitch.ctrl.test-nodejs-controller这个主题上接收
                        nc.request(service, JSON.stringify(rpc), { max: 1, timeout: 30000 }, (msg) => {
                            if (msg instanceof NATS.NatsError && msg.code === NATS.REQ_TIMEOUT) {
                                console.log('request timed out');
                            } else {
                                console.log('Got a response: ' + msg);
                                m = JSON.parse(msg);
                                if (m.result && m.result.code == 200) { // good
                                    console.log("so far so good");
                                }
                            }
                        });

3.3 FreeSWITCH及ESL开发者批南

如果你已经熟悉FreeSWITCH和ESL,也可以直接使用XCC API提供的NativeAPINativeApp进行开发。当然,如果你不熟悉,也可以直接忽略本节的内容。

XCC API可以完全替代ESL,也可以支持ESL中的inbound和outbound模式。本质上,ESL主要有三种概念和操作:

  • API:底层使用api命令实现,用于控制FreeSWITCH,向FreeSWITCH发送指令
  • App:底层使用sendmsg实现,用于执行FreeSWITCH中的Application
  • Event:事件订阅,以便实时知道FreeSWITCH中发生了什么

3.3.1 Event

事件订阅可以直接使用NATS提供的subscribe功能实现,可以订阅FreeSWITCH中原生的事件,如:cn.xswitch.ctrl.event.CHANNEL_ANSWER,或直接订阅所有原生事件:cn.xswitch.ctrl.event.>。事件的Subject前缀可以在配置文件中通过publish-events-subject-prefix指定,所需的事件类型可以在bindings部分配置,这些都需要在XSwitch侧的xcc.conf配置文件中指定,详见第章配置文件

原生事件以JSON-RPC格式发出,可以在各种语言中很简单的解析其中的内容。

3.3.2 inbound模式

在FreeSWITCH中,inbound和outbound都是相对FreeSWITCH而言的。前者相当于Ctrl侧连接到FreeSWITCH上主动控制它。在XCC中,实际上XNode和XCtrl双方都是连接到NATS上,因而可以互相收发消息。

Ctrl侧可以使用使用NativeAPI发送API命令,查询系统状态或发起通话,如:

{
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.NativeAPI",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "cmd": "status"
                            }
                        }
                        
                        {
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.NativeAPI",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "cmd": "originate",
                                "args": "user/1000 &echo"
                            }
                        }

当FreeSWITCH侧有来话时,可以通过Dialplan查询请求Ctrl侧动态生成Dialplan,或者,可以简单地直接执行park Application,Ctrl侧就可以收到CHANNEL_PARK事件,进而可以使用NativeApp进行应答,放音等处理,如:

{
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.NativeApp",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "uuid": "channel_uuid",
                                "cmd": "answer"
                            }
                        }
                        
                        {
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.NativeApp",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "uuid": "channel_uuid",
                                "cmd": "playback",
                                "args": "/tmp/welcome.wav"
                            }
                        }

3.3.3 outbound模式

实际上,上一节最后一个示例也相当于outbound模式,只是由于XNode和XCtrl之间的连接是“天然”的,因而XNode无需像ESL中那样再初始化一个连接。一旦连接建立,后续的控制流程都是一样的。

当然,XCC API也专门设计了一个xcc Application,类似于outbound模式与Ctrl侧连立一个“虚”连接,这在XNode与XCtrl多对多的情况下非常有用,这不属于原生的FreeSWITCH功能,但更强大,下面我们来做一个对比:

在ESL中,来话的Channel通过socket Application可以初始化一个outbound连接到你自己实现的ESL Server,你的ESL Server检测到连接到来时在底层回复一个“connect\n\n”来确认接收这个请求,然后FreeSWITCH才开始发送Channel Data事件以启动后续的交互。

在而XCC中,xcc也是一个Application,它向NATS广播一个Event.Channel事件,所有订阅并收到这个事件的Ctrl都可以竞争接管这路呼叫,接管可以通过AcceptAnswer实现。

所以,XCC API提供的NativeAPINativeApp完全可以代替原来ESL的功能,其它的XCC API功能更强大,且有更多的保护,推荐尽量使用非Native的XCC API。

3.3.4 track模式

有时候,在使用ESL或原生Dialplan的场景下,也希望接收XCC的Event.Channel消息,这时候可以使用xcc_track Application实现。使用方法:

xcc_track <cn.xswitch.ctrl.$ctrl_uuid>
                        answer
                        playback ...

XCC消息会发到对应的ctrl_uuid上,当且仅当在这种情况下,Event.Channel消息的state = TRACK

注意,该模式主要用于获取事件模式。在这种状态下,XCC API可能不能正常工作,因此,不建议使用XCC API控制Channel。

已知可以工作的XCC API如下:

  • DetectSpeech:仅支持异步模式,即id为空,使用publish而非request请求的模式,结果会发送到对应的ctrl_uuid上。
  • SetVar:设置通道变量。
  • GetVar:获取通道变量。
  • Hangup:挂机。

如果取消控制,可以使用xcc_untrack Application。

参见示例:https://git.xswitch.cn/xswitch/xcc-examples/src/branch/master/python#关于-asr-event-py

4 API列表

本系统提供的API称为XCC API。XCC API也支持调用FreeSWITCH底层原生的API,但原生API可能失去XCC的控制,请尽量使用XCC API。

  • Accept:用于来话,接管该呼叫然后什么也不做。
  • Ring:用于给来话播放回铃音。
  • Answer:应答。
  • Play:播放一个文件或TTS。
  • Stop:中断当前的API(如放音等)。暂未实现。
  • Broadcast:播放一个文件或TTS到通话中的双方。
  • Record:录音。
  • Hangup:挂机。
  • Dial:外呼。
  • Log:日志打印。
  • Bridge:用于将当前通话(来话或去话)桥接(呼叫)另一路通话。
  • ChannelBridge:桥接两部已经存在的通话,当桥接的通话完毕后返回消息。
  • ChannelBridge2:桥接两部已经存在的通话,当桥接成功后马上返回消息。
  • ReadDTMF: 播放一个语音并获取用户按键信息。
  • DetectSpeech:播放一个语音并获取语音识别结果。
  • StopDetectSpeech:停止语音检测。
  • RingBackDetection:回铃音检测。
  • DetectFace:人脸识别。
  • SendDTMF: 发送DTMF。
  • SendInfo:发送SIP INFO。
  • Mute: 通话静音
  • WaterMark:添加水印。
  • SetVar:设置通道变量(随路数据)。
  • GetVar:获取通道变量(随路数据)。

通用参数:

  • ctrl_uuid: 当前Controller的UUID。

4.1 Accept

接管来话,当前通话信息以后所有的事件都将会发到接管的Ctrl上。

  • uuid: 当前Channel的UUID。
  • takeover: true|false。用于多个Ctrl的场景,另一个Ctrl可以接替原来的Ctrl处理呼叫。

在呼入场景中,这应该是Ctrl在收到Event.Channel(state = START)后发送的第一个消息。否则,后续的API可能失败,但Answer除外,Answer会隐含Accept,这主要是为Ctrl在开发时提供一些便利。

4.2 Ring

用于播放回铃音。调用ring_ready发送SIP 180消息。

4.3 Answer

应答。

4.4 Play

播放本地的语音文件,或存放在HTTP服务器上的文件,或TTS等。

  • media:媒体

media参数定义:

  • type:枚举字符串,文件类型,见下文
  • data:字符串,语音文件或TTS
  • engine:字符串,TTS引擎,目前支持华为、迅飞、阿里、百度等。
  • voice:字符串,嗓音,由TTS引擎决定,默认为default
  • loop:正整数,重复次数,暂未实现。
  • offset:正整数,语音文件播放偏移量。以采样点为单位,如文件采样率为8000,则偏移80001秒。暂仅支持单个文件。对于file_string类型只对第一个文件起作用。

其中enginevoice为TTS参数。

文件类型有:

  • FILE:文件
  • TEXT:TTS,即语音合成
  • SSML:TTS,SSML格式支持(并非所有引擎都支持SSML)

在播放的过程中,会检测DTMF并以事件(Event.DTMF)形式发出。

4.5 Stop

停止播放。

4.6 Broadcast

播放本地的语音文件,或存放在HTTP服务器上的文件,或TTS等。如果uuid处于BRIDGE状态,则播放到通话的双方。

  • uuid:当前UUID。
  • media:媒体,参见Play。
  • option:参数,默认为BOTH

option参数:

  • BOTH:双方。
  • ALEG:只有当前UUID能听到。
  • BLEG:当前UUID的对端能听到。
  • AHOLDB:同ALEG,同时对B放保持音乐(如果配置了的话)。
  • BHOLDA:同BLEG,同时对A放保持音乐。

4.7 Record

录音。传入相同的path可以开始和结束录音。录音将存储在FreeSWITCH本地文件,Ctrl收到相关信息后可以通过其它方式下载录音。具体录音文件的存储不在本文档范围内。

录音有两种情况:

  • 非阻塞录音:后台在单独的线程中启动录音,不影响当前操作,如可以继续Play或Bridge。

  • 阻塞录音:适用于单腿的,语言留言之类的情况。

  • path:文件路径。

  • limit:可选,时长,单位为秒。

  • action:动作。

动作可以有:

  • RECORD:阻塞录音。
  • START:开始录音。
  • STOP:结束录音。
  • MASK:屏蔽敏感信息,如DTMF或密码等。
  • UNMASK:停止屏蔽。

4.7.1 非阻塞录音

在非阻塞录音会立即返回,可以在腿上执行后续的操作如Play或Bridge等。非阻塞录音无法获取录音结果,但相关结果可以通过监听RECORD_STARTRECORD_STOP事件获取。

4.7.2 阻塞录音

阻塞录音有以下参数:

  • beep:播放一个“嘀”声,可以输入default,或者任何合法的TGML:https://freeswitch.org/confluence/display/FREESWITCH/TGML 。
  • terminators:字符串如1234,按键可以打断录音。
  • thresh:VAD声音检测阈值,0为禁用。1~100000,值越大声音越小,即越敏感。
  • silence_seconds:静音时长,如果检测到静音超过这个时长则自动停止录音。

阻塞录音将在录音结束后返回结果。

4.7.3 返回值

  • terminator:如果录音被打断,则返回相关按键。
  • path:文件路径。

4.8 Hangup

挂机。

  • flag integer 值为

    • 0:挂断自己
    • 1:挂断对方
    • 2:挂断双方
  • cause:挂机原因,参见Dial中相关定义。

  • data:是一个对象,可以同时设置多个属性。如果挂机前需要设置channel变量,可以使用该字段。该字段设置后,会自动订阅,不需要再修改配置文件或者使用SetVar接口去专门订阅。

例子:

{
                            "method": "XNode.Hangup",
                            "params": {
                                "flag": 0,
                                "data": {
                                    "var1": "value1",
                                    "var2": "value2"
                                }
                            }
                        }

4.9 ReadDTMF

播放一个语音并获取用户按键信息,将在收到满足条件的按键后返回。

  • min_digits:最小位长。
  • max_digits:最大位长。
  • timeout:超时,默认5000ms
  • digit_timeout:位间超时,默认2000ms
  • terminators:结束符,如#
  • data:播放的媒体,可以是语音文件或TTS。

返回结果:

  • dtmf:收到的按键。
  • terminator:结束符,如果有的话。

本接口将在收到第一个DTMF按键后打断当前的播放。

4.10 DetectSpeech

检测语音,可以同时检测DTMF。如果不需要检测语音,仅检测DTMF,请使用ReadDTMF接口。

  • media:媒体,参见Play。
  • dtmf:DTMF参数,参见ReadDTMF中的定义,如果不需要同时检测DTMF,可以不传该参数。
  • speech:对象,语音识别请求。

speech定义:

  • engine:字符串,ASR引擎。
  • no_input_timeout:正整数,未检测到语音超时,默认为5000ms。
  • speech_timeout:正整数,语音超时,即如果对方讲话一直不停超时,最大只能设置成6000ms,默认为6000ms。
  • max_speech_timeout:正整数,语音最大超时,和参数speech_timeout作用相同,如果max_speech_timeout的值大于speech_timeout,则以max_speech_timeout为主,用于一些特殊场景的语音时长设置。
  • partial_events:Bool,是否返回中间结果。有些引擎支持返回中间识别结果。以事件形式发出。
  • nobreak:Bool,禁止打断。用户讲话不会打断放音。放音期间识别到用户讲话的内容将全部缓存。多“句”话之间使用“”拼接成一个text字段发出。其它字段如confidence等将使用第一个返回的结果,可能没有代表性。
  • merge_delimiter:String,合并分隔符。暂未实现。默认为“”。在nobreak状态下,如果需要拼接结果中插入分隔符。
  • disable_detected_data_event:Bool,默认会发送Event.DetectedData事件,如果为true则不发送。
  • params:不同引擎特定的参数,全部为字符串键值对,由具体的引擎定义。

识别结果:

  • uuid:当前Channel的UUID。
  • text:识别到的文本。
  • confidencedouble类型,结果可信度,跟具体引擎相关。
  • is_final:是否是最终结果。
  • engine_data:ASR引擎原生返回的数据,适用于可以进一步获取更多信息。 
  • error:错误,如no_inputspeech_timeout等。
  • type:结果类型,有DTMFSpeech.BeginSpeech.PartialSpeech.EndSpeech.MergedERROR等。
  • offset:如果是文件播放过程中被打断,则该值为打断时的偏移量。可以在打断位置继续播放。

本接口将在第一时间检测到语音后打断当前的播放。可以加一个参数控制是否打断,暂未实现。

正常情况下,本接口会在检测到结果后返回,以便进行下一轮交互。

本接口也可以支持连续识别。如果在调用时不传任何media参数,则会启动后台识别,识别到的结果以事件(Event.DetectedData)形式发出。在连续识别过程中,可以继续进行放音操作。(本操作需要相应的后台设置)。

分段录音:

分段录音在ASR引擎上实现,因此做为引擎的参数(params)传入。

  • segment-recording-prefix:分段录音路径前缀,将与真正的文件名拼接。如果前缀是一个目录,必须以/结尾,如/tmp/,建议使用带时间戳的前缀,如/tmp/20200202-121212-。文件名后半部分暂时使用引擎相关的唯一值,如阿里云的引擎中我们使用header.message_id作为文件名。

4.11 DetectFace

人脸识别接口。本接口将在后台启动一个Media Bug。并将通话置于Echo状态(即客户会看到当前自己的人脸)。

在人脸识别过程中,每隔1.5秒返回一张图片(PNG格式经过Base64算法转成data-url)。业务侧可以将图片传到外部人脸识别系统中进行比对。

相关接口参考:

  • https://cn.aliyun.com/product/bigdata/product/face

  • https://cloud.tencent.com/document/product/867/44987

  • uuid:当前通话UUID,必填。

  • mask:PNG图片作为人脸型的框架,一般为透明图片,默认为/usr/local/freeswitch/images/mask.png

  • action:字符串。START:开始。STOP:结束。TEXT:写文字。CLEAR:清除文字。

如果action为TEXT,则有以下参数:

  • text:文本
  • font:字体文件路径
  • font_size:字体大小(像素)
  • fg_color:前景色
  • bg_color:背景色

结果:

  • uuid:UUID
  • picture:data-url格式的字符串
  • width:图像宽度
  • height:图像高度

本接口为非阻塞接口,调用者在首次调用action = START后,将收到code = 202消息。然后调用者应该循环待,后续的结果中将有picture返回,然后可以将picture传给第三方接口进行比对或识别。识别完毕可以STOP结束识别。

识别过程中,可以调用action = TEXT在图片上添提提示文本(如请转头、请眨眼等)。

4.12 SendDTMF

  • uuid:当前通话UUID,必填。
  • dtmf:DTMF字符串,必须是合法的DTMF。

合法的DTMF如下:

  • 0~9:数字
  • A~F:特殊按键
  • w:停顿500毫秒
  • W:停顿1秒

4.13 SendInfo

  • uuid:当前通话UUID,必填。
  • content_type:可选,字符串,默认为text/plain
  • data:可选,字符串,默认为空字符串。

使用该功能需要设置通道变量fs_send_unsupported_info=true,或直接开启全局的变量。

4.14 Dial

外呼。直接同时发起多个呼叫,但其中任意一个接听后,其它的呼叫都将挂机。

  • ringall:true/false。同振,默认为顺振。
  • global_params:FreeSWITCH 呼叫字符串{}里的参数,作用于所有的腿,具体请参见FreeSWITCH中相关定义。
  • call_params:呼叫参数,是一个数组,数组中的每个对象都代表一条腿。所有的键和值都必须是字符串。

呼叫参数定义:

  • uuid:新Channel的UUID,必须由Ctrl生成,并且所有Channel生存周期内不能重复(建议永远不要重复),建议使用相关UUID算法生成。
  • cid_number:主叫号码。
  • cid_name:主叫名称。
  • dial_string:FreeSWITCH原生呼叫字符串,如user/xxxsofia/gateway/gwx/xxxx等。以后的版本中可以增加相关的对象代替FreeSWITCH原生的字符串。详情参阅附录中「呼叫字符串」部分。
  • max_duration: 最长通话时间,单位是秒。最小60秒,最长24 * 3600秒,即24个小时,缺省值为24 * 3600秒。
  • params:新Channel的相关参数,对应FreeSWITCH中呼叫字符串的[]里面的定义,仅作用于这一条腿。此外的参数会被global_params中的同名参数覆盖。

外呼成功后,如果是同步请求,则可以直接返回结果,否则可以异步的获取结果,并等待有Event.Channel(state = READY)消息后开始下一步操作。

注意: 该命令会在成功收到媒体后返回,比如在SIP消息中,收到183时就返回,以便进行后续的操作。如果需要在对方接听后返回,则可以使用ignore_early_media=true参数。

呼叫过程中相应的呼叫进展后有相关的Event.Channel事件发送到当前Ctrl上,如cn.xswitch.ctrl.<ctrl_uuid>。

外呼的呼叫会自动接管,不需要发送Accept

外呼的结果中cause为成功或失败原因,列表如下:

  • SUCCESS:成功,可以进行下一步操作。
  • USER_BUDY:被叫忙。
  • CALL_REJECTED:被叫拒接。
  • NO_ROUTE_DESTINATION:找不到路由。

更多定义参见第节常用挂机原因说明

JSON示例:

{
                            "jsonrpc": "2.0",
                            "id": "request-uuid",
                            "method": "XNode.Dial",
                            "params": {
                                "ctrl_uuid": "some-uuid",
                                "ringall": true
                                "global_params": {
                                    "ignore_early_media": "true -- must be a string",
                                    "caller_id_number": "110"
                                },
                                "call_params": [{
                                        "uuid": "uuid",
                                        "cid_number": "7777",
                                        "dial_string" = "user/1000"
                                }, {
                                        "uuid": "uuid"
                                        "dial_string" = "sofia/gateway/test/1000",
                                        "params": {
                                                "leg_timeout": "20 -- must be a string",
                                                "ignore_early_media": "true -- must be a string"
                                        }
                                }]
                            }
                        }

4.15 Log

日志打印。

通过调用本接口可实现在XSwitch内打印想要输出的日志信息。该接口不依赖于通话,可在任意处进行调用。

  • level:日志级别。字符串类型。可从以下级别中任选其一。默认为DEBUG

    • DISABLE
    • CONSOLE
    • ALERT
    • CRIT
    • ERR
    • WARNING
    • NOTICE
    • INFO
    • DEBUG
  • function:调用该接口代码所在函数名称。

  • file:调用该接口代码所在文件名称。

  • line:调用该接口代码行数。整型。

  • log_uuid:任意字符串。可选。建议为当前通话UUID

  • data:想要打印的日志信息。

注意,以上六个参数中,只有log_uuid为可选参数,其他均为必填

JSON示例:

{
                            "jsonrpc": "2.0", 
                            "method": "XNode.Log", 
                            "params": {
                                "ctrl_uuid": "f8a02eed-3ea6-42a2-838d-856f529d3fbc", 
                                "level": "ALERT", 
                                "function": "xnode_status", 
                                "file": "log.js", 
                                "log_uuid": "", 
                                "line": 69, 
                                "data": "Hello, this is a test"
                            }, 
                            "id": "fake-log"
                        }

XSwitch内日志打印如下: 2022-07-14 13:04:07.427643 98.80% [ALERT] log.js:69 Hello, this is a test

4.16 StartDTMF

开启带内DTMF检测。

  • force:可选参数,是否强制开启带内DTMF检测。

本接口将可能或者强制开启带内DTMF检测。使用本接口时,如果不带force参数或者其值为flase的情况下,只有实际通话协商DTMF不是2833的情况下,才会开启带内DTMF检测,因为对于大部分通话来说,DTMF都是2833,这种情况是没必要开启带内检测的,如果开启会增加相应的CPU开销。当参数force值为true时,将强制开启带内DTMF的检测,因此一般情况下不建议开启此参数。

4.17 Bridge

将当前通话桥接(发起)另一个呼叫。

该功能会发起一个新的呼叫(在a-leg的基础上创建b-leg)。呼叫参数与Dial相同。a-leg会听到b-leg的回铃音。也可以通过ignore_early_media=true参数抵制回铃音。

  • flow_control:呼叫控制,跟程控交换机中的控制方式类似,略有不同。
    • NONE:无控制,任意方挂机不影响其它一方
    • CALLER:主叫控制,a-leg挂机后b-leg自动挂机
    • CALLEE:被叫控制,b-leg挂机后a-leg自动挂机
    • ANY:互不控制,任一方挂机后另一方也挂机
  • contine_on_fail:字符串类型。该参数控制在Bridge失败的情况下的逻辑。如果Bridge失败(如空号、被叫忙等),则是否继续:
    • true:继续,继续保持PARK状态
    • false:挂机
    • USER_BUSY,NO_ANSWER...:逗号分隔的字符串列表,如果遇到列表中的挂机原因则继续(即白名单),否则会自动挂机。

4.18 ChannelBridge

桥接两个uuid,两个Channel必须为未Bridge状态,且处于PARK状态(没有执行其它App)。 当unbridge之后返回这个api调用的回复消息。

  • uuid:当前uuid,为a-leg
  • peer_uuid:对方uuid,为b-leg
  • flow_control:桥接成功后是否自动挂机,参见Bridge中相关定义。

4.19 ChannelBridge2

桥接两个uuid,两个Channel必须为未Bridge状态,且处于PARK状态(没有执行其它App)。
当桥接完成后马上返回api调用的回复消息

  • 参数与ChannelBridge相同

4.20 Unbridge

A与B通话中。将A与B分离,分别park。

4.21 Hold

将通话置于保持状态。

内部要在controller中记住状态。

4.22 Unhold

解保持,清除保持状态。

4.23 Transfer

转移。如果当前uuid只有一路通话,则直接Bridge destination,跟Bridge一样处理。

如果当前uuid处于Bridge状态,则挂断peer_uuid,然后执行Bridge。

message TransferRequest {
                            Ctrl ctrl_uuid = 1;
                            string uuid = 2;
                            string ringback = 3;
                            Destination destination = 4;
                        }

4.24 ThreeWay

三方。A与B通话,用Dial呼叫C或C呼入,然后通过该API建立三方。其中uuid为C,target_uuid为A或B。

direction有以下取值(下文中,C为uuid,A为target_uuid,B为target_uuid的对端):

  • LISTEN:监听模式,C能听到A和B,A和B听不到C
  • ABC:三方模式,三方互相能听到其它两方
  • AC:A与C通,B听MOH
  • BC:B与C通,A听MOH
  • TOA:C听AB,A听BC,B听A,即A与B正常通话,但C可以单独跟A讲话(耳语)
  • TOB:C听AB,B听AC,A听B,即A与B正常通话,但C可以单独跟B讲话(耳语)
  • STOP:停止三方
message ThreeWayRequest {
                            Ctrl ctrl_uuid = 1;
                            string uuid = 2;
                            string target_uuid = 3;
                            string direction = 4;
                        }

在ThreeWay的过程中,如果初始化状态为LISTENABC模式(其它状态无法自由转换),可以传入不同的direction并改变通话逻辑。如,最开始使用LISTEN模式兼听,然后改成使用TOA模式跟A耳语,然后改成ABC模式三方,最后STOP停止。

4.25 Intercept

强插。A与B通话中。C呼入或者通过Dial呼通C,uuid为C,然后target_uuid为A或B,建立通话后挂掉另一方。

message InterceptRequest {
                            Ctrl ctrl_uuid = 1;
                            string uuid = 2;
                            string target_uuid = 3;
                        }

4.26 Echo2

在通话中听到自己发出的声音。

  • actionSTART(默认) | STOP,开始或停止
  • directionSELF(默认)| OTHER,听到自己的声音,或让对方听到自己的声音。

4.27 Mute

静音,在通话中静音

参数:

  • uuid: 当前session ALEG的uuid
  • directionn:静音方向。WRITE:静音ALEG发送出去的声音;READ:静音BLEG发送过来的声音;BOTH:静音ALEG和BLEG。
  • level: integer0 非静音,1 静音,大于1 静音并发送舒适噪声数值代表舒适噪音的音量。
  • flag : media bug插入顺序, FIRST代表插入到最头上,LAST代表插入到最尾。

4.28 WaterMark

通话过程中添加水印。

参数:

  • actionSTART | STOPRESTART,增加水印,移除水印及覆盖水印
  • marked_text:文字水印,显示在左上角
  • marked_with_time:时间水印,显示在左上角
  • marked_fsize:水印字体大小
  • marked_fcolor:水印字体颜色
  • marked_fface:水印字体,默认为FreeMono
  • marked_file:文件水印,通常是图片
  • marked_file_mode:文件水印显示模式 - logo:显示在右下角,长宽占比不超过1/4 - bottom:靠下侧居中显示 - top:靠上侧居中显示 - custom:自定义
  • marked_file_alphatrue | false,水印显示是否使用透明模式
  • marked_abs_x:文件水印自定义模式的横坐标
  • marked_abs_y:文件水印自定义模式的纵坐标

4.29 SetVar

设置通道变量(随路路数据),变量一经设置后在Channel生命周期内会一直有效。

参数:

  • uuid:当前Channel的的UUID
  • data:数据,是一个对象,可以同时设置多个属性。
  • channel_params:字符串数组,同时订阅这些变量,以便以后的Event.Channel事件中携带。

如:

{
                            "method": "XNode.SetVar",
                            "params": {
                                "data": {
                                    "var1": "value1",
                                    "var2": "value2"
                                },
                                "channel_params": [
                                    "var1",
                                    "var2",
                                    "source",
                                    "context"
                                ]
                            }
                        }

其中、datachannel_params参数都是可选的这样设计的原因是可以在设置通道变量的同时进行订阅,而不需要再使用一个API订阅一次。如果只想订阅通道变量而不设置,则可以省略data参数。

4.30 GetVar

获取通道变量(随路路数据)。如果想临时获取一些通道变量,可以使用本函数。

4.31 FreeSWITCH原生API

系统提供执行FreeSWITCH原生API的能力,用于在XCC API不够用的情况下使用。如果某NativeApi使用频繁,建议包装成XCC API。

  • NativeAPI:可执行所有的FreeSWITCH原生的API
  • NativeApp:可执行所有的FreeSWITCH原生的App
  • NativeJSAPI:可执行所有的FreeSWITCH原生的JSON API

4.31.1 NativeAPI

message NativeRequest {
                          string ctrl_uuid = 1;
                          string cmd = 2;
                          string args = 3;
                        }
  • cmd:FreeSWITCH原生命令,如statussofia
  • args:FreeSWITCH命令参数,如status命令没有参数,可以为空,sofia命令的参数status等。

4.31.2 NativeApp

message NativeRequest {
                          string ctrl_uuid = 1;
                          string cmd = 2;
                          string args = 3;
                          string uuid = 4;
                        }
  • cmd:FreeSWITCH原生App,如answerechoconference
  • args:FreeSWITCH命令参数,如echo命令没有参数,可以为空,conference的参数可以是一个会议室名称,如3000

4.31.3 NativeJSAPI

message NativeJSRequest {
                          string ctrl_uuid = 1;
                          message NativeJsData {
                            string command = 1;
                            // a string or a native JSON struct to google.protobuf.Any or .Struct
                            string data = 2;
                          }
                          NativeJsData data = 2;
                        }

原生的JSAPI。

  • command:JSAPI命令,如conferenceInfo
  • data:JSAPI命令参数,可以是字符串形式,也可以是JSON形式,必须是一个Object,如:{}

示例请求:

{
                            "jsonrpc": "2.0",
                            "id":"status",
                            "method": "XNode.NativeJSAPI",
                            "params": {
                                "ctrl_uuid":"test-nodejs-controller",
                                "data": {
                                    "command": "status",
                                    "data":{}
                                }
                            }
                        }

响应:

{
                            "jsonrpc":  "2.0",
                            "id":   "status",
                            "result":   {
                                "code": 200,
                                "message":  "OK",
                                "node_uuid":    "xcc-node-1",
                                "data": {
                                    "systemStatus": "ready",
                                    "uptime":   {
                                        "years":    0,
                                        "days": 0,
                                        "hours":    2,
                                        "minutes":  54,
                                        "seconds":  23,
                                        "milliseconds": 607,
                                        "microseconds": 581
                                    },
                                    "version":  "1.10.7-dev git b208d55 2021-06-15 03:52:14Z 64bit",
                                    "sessions": {
                                        "count":    {
                                            "total":    5,
                                            "active":   0,
                                            "peak": 1,
                                            "peak5Min": 0,
                                            "limit":    20000
                                        },
                                        "rate": {
                                            "current":  0,
                                            "max":  20000,
                                            "peak": 1,
                                            "peak5Min": 0
                                        }
                                    },
                                    "idleCPU":  {
                                        "used": 0,
                                        "allowed":  95.36666666666666
                                    },
                                    "stackSizeKB":  {
                                        "current":  240,
                                        "max":  8192
                                    }
                                }
                            }
                        }

5 Dialplan接管

XSwitch的mod_xcc模块中实现一个XCC Dialplan接口,使用该Dialplan后,XSwitch在每次查询Dialplan时都会向Ctrl发送request请求。

  • subject:cn.xswitch.ctrl
  • method:XCtrl.Dialplan

Ctrl侧返回一个数组:

[
                            {"app": "answer", "data":""}
                            {"app": "echo"}
                        ]

XSwitch会顺序执行数组中的app,这些app都是XSwitch原生的Application。

6 XML Binding

  • subject:cn.xswitch.ctrl
  • method:XCtrl.FetchXML

XSwitch通过XML Binding实现动态XML。相关概念可参考mod_xml_curl

https://freeswitch.org/confluence/display/FREESWITCH/mod_xml_curl

在XSwitch内部实现一个绑定回调函数,XSwitch在每次需要XML时,都会回调该函数。

mod_xcc中,每次绑定的回调都会发送request请求。

请求示例如下:

{
                            "jsonrpc":  "2.0",
                            "method":   "XCtrl.FetchXML",
                            "id":   "5ca1bf44-1f80-4845-b538-12ca29086e0e",
                            "params":   {
                                "node_uuid":    "5cc473e8-ad01-481e-9f28-9291b1bad9d5",
                                "section":  "configuration",
                                "tag_name": "configuration",
                                "key_name": "name",
                                "key_value":    "sofia.conf",
                                "params":   {
                                }
                            }
                        }

可以绑定如下Section:

  • dialplan:拨号计划,路由。不推荐使用,推荐使用XCC Dialplan代替。
  • configuration:配置,模块的配置等。
  • directory:用户目录。
  • channels:动态Channel,在转接的时候有用。
  • chatplan:即时消息的路由。
  • languages:多语言支持。

XSwitch内部支持多个绑定,多个绑定之间的执行顺序不固定,因而可能会有冲突,慎用。

服务方收到XSwitch的请求后,返回正常的XML文档即可:(其中$相关的变量可以从请求中获取)

<document type="freeswitch/xml">
                                <section name="${params.section}">
                                <${params.tag_name} ${params.key_name}="${params.key_value}"/>
                                ... 实际的内容
                                </section>
                        </document>

如果“实际的内容”为空,则FreeSWITCH找不到相关的数据。FreeSWITCH也不会Fallback到其它数据。

如果服务方不提供数据,但允许FreeSWITCH去查询其它绑定,则返回not found消息:

<document type="freeswitch/xml">
                                <section name="result">
                                    <result type="not found"/>
                                </section>
                        </document>

如果没有其它绑定,或者绑定没有找到数据,FreeSWITCH会继续查询本地的静态XML配置。

如果希望FreeSWITCH跳过所有绑定直接查询本地静态XML,则可以返回一个空的XML,如:

<document type="freeswitch/xml"/>

Fallback规则在实际使用时会增加复杂性,有时候不知道哪个配置是生效的,因此,慎用。

6.1 Directory绑定

当用户注册时或作为被叫时发送请求,请求示例如下:

{
                            "jsonrpc":  "2.0",
                            "method":   "XCtrl.FetchXML",
                            "id":   "e54b7f58-2edc-4a13-a8d8-8c74b854cb39",
                            "params":   {
                                "node_uuid":    "f52310ba-12c0-40a0-b0d3-f28a8deed5ab",
                                "section":  "directory",
                                "tag_name": "domain",
                                "key_name": "name",
                                "key_value":    "seven.local",
                                "params":   {
                                    "key":  "id",
                                    "user": "1007",
                                    "domain":   "seven.local",
                                    "ip":   "172.22.0.1"
                                }
                            }
                        }

服务方应该返回XML字符串。

6.2 Configuration绑定

请求示例如下:

{
                            "jsonrpc":  "2.0",
                            "method":   "XCtrl.FetchXML",
                            "id":   "5ca1bf44-1f80-4845-b538-12ca29086e0e",
                            "params":   {
                                "node_uuid":    "5cc473e8-ad01-481e-9f28-9291b1bad9d5",
                                "section":  "configuration",
                                "tag_name": "configuration",
                                "key_name": "name",
                                "key_value":    "sofia.conf",
                                "params":   {
                                }
                            }
                        }

服务方应该返回XML字符串。

7 录音

详细的录音解决方案超出了本文档的范围,下面仅就实现方式做相关探讨和建议。

  • 出于性能考虑,建议录音使用.wav格式,由外部程序延时转成mp3可以节省存储空间。
  • 录音可以存储在任何目录下,建议存放到/usr/local/freeswitch/storage/recordings/,文件名建议有日期前缀,如20200202-121212-
  • 通过Web服务器(如Nginx)配置,可以直接将录音文件映射到相关URL方便外部程序获取,如http://localhost/recordings/20200202-121212-xxxxxx.wav
  • 录音也可以通过共享NAS实现。
  • 录音可以由外部程序监控,如通过消息队列或本地的inotify接口获取录音完成,然后将录音移到或上传到其它云存储上。
  • 录音文件名中可以添加一些元数据以便其它脚本处理,如主被叫号码等。
  • 分段录音由具体的语音识别引擎实现。目前仅有ali引擎实现了分段录音。

8 呼叫场景

注意事项:

  • 在有电话呼入时,Ctrl在收到START消息后应该在10秒内调用AcceptAnswer接口接管呼叫。
  • 除了一些特殊应用(如回彩铃等),应该尽快应答。某些PSTN网管在应答前不允许与对方语音交互,未应答的呼叫一般也会被对方超时拆线(如60秒)。
  • 应答后,在有延迟的中继网管情况下,可能出现放音的前几个字对方听不到的情况,这时可以在应答后延迟1-2秒再发送后续指令。
  • 在外呼时(尤其是在对接PSTN网关时),Dial接口会在收到媒体时返回(如SIP中的183消息),如果需要在应答后返回,收需要加ignore_early_media=true参数。

8.1 简单IVR

  • Ctrl监听cn.xswitch.ctrl
  • Ctrl收到Event.Channel(state = START)
  • Ctrl执行AcceptAnswer
  • Ctrl调用Play或TTS播放欢迎音
  • Ctrl调用ReadDTMFDetectSpeech检测按键或语音
  • Ctrl检查到语音后调用后续操作

8.2 桥接

桥接即将两条独立的腿接到一起,使双方可以通话。

8.2.1 简单桥接

这种模式最初只有一条腿,执行Bridge接口创建另一条腿。

a-leg呼入,或者使用Dial外呼建立,在a-leg上调用Bridge接口发起另一路呼叫,呼叫成功后。使用如下控制逻辑:

  • NONE:默认。在这种模式下,不管任意一方挂机,都不影响另外一方。另一方仍处于PARK状态,可以执行后续的逻辑,如转“评价”等。
  • ANY:互不控制。在这种模式下,只要Bridge成功,双方开始通话后,任一方挂机另一方自动挂机。控制比较简单。
  • CALLER:主叫挂机另一方自动挂机,但被叫挂机不影响主叫侧。
  • CALLEE:被叫挂机另一方自动挂机,但主叫挂机不影响被叫侧。

如果任一方不能自动挂机(在另一方挂机后己方回到PARK状态),则应该在Bridge成功后,等待UNBRIDGE事件到来后,再执行下一步操作。

8.2.2 呼叫不成功处理

在上述的简单桥接的情况下,如果呼叫不成功,则flow_control不起作用。需要使用contine_on_fail参数控制。

true:默认,这种情况下a-leg会继续处于PARK状态,这时候可以重试呼叫(Bridge),或者使用一个备用号码或备用网关进行呼叫(Bridge) false:自动挂机。 原因列表:以逗号分隔的FreeSWITCH原因列表,如USER_BUSY,NO_ANSWER

使用该参数可以实现遇忙转移、无应答转移、通过第二路由或第二网关重新呼叫等。

如果呼叫不成功,可以根据Bridge返回结果中的原因值(cause)决定下一步该怎么做。

8.2.3 ChannelBridge

适用于两条腿已经存在的情况。也可以通过flow_control参数控制是否自动挂机。

8.3 机器人外呼

  • Ctrl调用Dial进行呼叫。
  • Dial返回后,Ctrl播放欢迎音,或调用检测DTMF或进行语音识别。
  • Ctrl根据收到的结果做下一步动作。
  • 如果需要转坐席,调用Bridge呼叫座席,或使用ChannelBridge桥接一个已经存在的座席(可能是通过Dial呼出的,或座席主动呼入并等待的)。

9 机器人监听外呼

  • Ctrl调用Dial呼A(座席),ignore_early_media=true
  • 呼通后,Ctrl调用Dial呼B,ignore_early_media=false,以便A能听到回铃音
  • B state = READY后,调用ThreeWayuuid = A, target_uuid = B, direction=LISTEN,即A监听B,此时A能听到B的回铃音,A能收到state = BRIDGE消息
  • B state = ANSWERED后,调用DetectSpeech与客户交互
  • 如果A相介入与客户的通话,则先停止监听(可以在B上的最后一次DetectSpeech的回调里执行,不再继续执行DetectSpeech,也可以结束掉DetectSpeech),调用ThreeWayuuid = A, direction=STOP
  • A侧收到state = UNBRIDGE后,表明监听停止,然后调用ChannelBridgeuuid=A, peer_uuid=B
  • A和B都会收到state = BRIDGE,A与B通话建立成功。

10 配置文件

配置文件为xcc.conf,包含以下几个部分。

10.1 settings

参数设置。

  • debug:调试日志级别,0~7
  • nats-url:NATS的地址,如nats://127.0.0.1:4222,如果不配置则不连接NATS。
  • publish-events-subject-prefix:事件的Subject前缀,如.event,将会变为cn.xswitch.ctrl.event.CHANNEL_ANSWER之类。
  • cdr-format:CDR的格式,可以在CDR中选一种格式,见CDR部分。
  • cdr-subject:CDR专门的Subject,用于将CDR送到专门的Subject上,如果不配置默认送到Channel对应的Controller上。
  • xml-handler-bindings:XML绑定,不同的Section以|分隔,如directory|config
  • loki-push-url:loki接受log push的地址,如http://127.0.0.1:3100/loki/api/v1/push,配置该参数则表示日志消息直接推送到loki。如不配置则日志使用消息队列的方式向loki推送日志。 注意:/api/prom/push 已经弃用,使用/loki/api/v1/push loki文档
  • loki-send-gapinteger 推送时间间隔,单位毫秒。超过这个时间间隔会推送所有缓存的日志 默认值1000毫秒
  • loki-max-log-cache-countinteger 缓存日志最大的条数,超过这个数量会将缓存日志推送到loki 默认值20
  • loki-max-log-cache-mem-sizeinteger 缓存日志最大内存占用值,单位是kb,超过这个内存占用会将缓存的日志推送到Loki,默认值为4
  • loki-cache-queue-sizeinteger 缓存队列容量 默认值300

10.2 bindings

事件绑定。绑定的事件会向NATS发送,ALL会绑定所有事件。

发送的Topic可以由publish-events-subject-prefix参数改变,如:

  • cn.xswitch.ctrl.event.DTMF
  • cn.xswitch.ctrl.event.channel_answer
  • cn.xswitch.ctrl.event.custom.sofia::register
  • cn.xswitch.ctrl.event.custom.conference::maintenance

通话相关事件可绑定如下事件:

  • CHANNEL_CREATE //通话创建事件
  • CHANNEL_STATE //状态
  • CHANNEL_ANSWER //应答
  • CHANNEL_HANGUP_COMPLETE //挂机结束

10.3 subs

向NATS订阅。

  • name:Topic,可以是任意合法的NATS Subject或Kafka Topic。其中NATS支持queue订阅。
  • queue:如果参数存在则在Queue方式订阅,即在集群订阅时,同一个Queue的订阅只会有一个订阅者收到。

10.4 cdr

CDR参数。CDR格式。

  • lega | b | ab,记录哪条腿的话单。
  • name:出现在JSON中的字段名。
  • value:对应FreeSWITCH中通道变量的字段名。

如:

<cdr leg="a">
                            <param name="caller_id_name" value="caller_id_name"/>
                            <param name="caller_id_number" value="caller_id_number"/>
                            <param name="cid_number" value="caller_id_number"/> <!-- 别名 -->
                        </cdr>

10.5 channel-params

通道相关参数。为了节省带宽,XSwitch仅在Channel事件中携带很少的必要的信息,但如果在特定的场景中需要更多的信息,则可以通过配置从通道变量中获取。这些信息将显示在Channel事件的params对象中,所有数据均为字符串。

  • name:显示在JSON对象params字段中的名称,建议与value一致
  • value:XSwitch通道变量名称,可以通过“|”指定另一个变量,当“|”前面的不存在时,尝试从第二个变量获取
  • default_value:可以指定一个默认值。特殊情况下使用,一般没有什么意义,不推荐使用。

如:

<channel-params>
                            <param name="user_uuid" value="user_uuid"/>
                            <param name="user_domain" value="user_domin"/>
                            <param name="context" value="context|first_dialplan_context"/>
                        </channel-params>

以下链接中包含一个常用通道变量列表,其中,上述配置中的value字段对应表格中的“channel variable name”列:

https://freeswitch.org/confluence/display/FREESWITCH/Channel+Variables

10.6 dialplan-params

Dialplan请求参数,附加在Dialplan请求中。namevalue的含义与channel-params中相同。如:

<dialplan-params>
                            <param name="cid_name" value="caller_id_name"/>
                            <param name="cid_number" value="caller_id_number"/>
                            <param name="dst_number" value="destination_number"/>
                            <param name="context" value="context"/>
                            <param name="direction" value="direction"/>
                        </dialplan-params>

11 XSwitch集群

本章讨论XSwitch集群组网。

11.1 来话处理

来话处理如下图所示。假设中继侧来话可以以一定算法分配置到3个不同的XSwitch Node节点。XNode收到呼叫后,向NATS广播来话消息(Event.Channel(state = START)),Ctrl收到后进行处理。

所有XNode都订阅至少两个Subject:

  • cn.xswitch.node:所有XNode都以同一个Queue订阅,发往该Subject的消息只有一个Node能收到,也就是说接收消息是互斥的。
  • cn.xswitch.node.n:其中n = 1,2,3 ...,每个Node独立订阅,发往该Subject的消息只有一个能收到,也就是说,可以通过它定向发消息。

同理,所有Ctrl都订阅两个Subject:

  • cn.xswitch.ctrl:以同一个Queue订阅,互斥接收。
  • cn.xswitch.ctrl.n:其中n = 1,2,3 ...,每个Ctrl独立订阅。

当有来话时,不失一般性,假设该来话分发到了node.1,则它会构造一个消息,通过NATS发送到cn.xswitch.ctrl上,右侧两个Ctrl都有可能收到该消息,但只有一个收到。不失一般性,假设ctrl.1收到了这个消息,这时候,它从消息的内容中取出node_uuid,知道了该消息是来自node.1,因此,它往node.1上回复一个XNode.Accept指令,表示它想接管这一路呼叫。node.1收到后,向ctrl.1回复200 OK,表示指令执行成功。至此,Node和Ctrl通过NATS建立了一个“虚连接”(一对一对应关系),在这路通话生存期间都是如此。

时序图如下所示:

11.2 去话处理

去话逻辑与来话差不多,区别只是控制首先从Ctrl发起。假设ctrl.1发起呼叫,它首先将XNode.Dial请求发到cn.xswitch.node这个Subject上,不失一般性,假设node.1收到了这个请求,在后续的返回结果中,它会告诉ctrl.1这个呼叫是在node.1上处理了,因而也可以建议虚连接。

如果Ctrl想将Dial请求发到指定的Node上,也可以直接指定Node对应的Subject,如cn.xswitch.node.1。至于Ctrl如何知道有哪几个Node,参见下一节节点管理。

11.3 节点管理

  • 每个Node在上线时都会主动发Node.Register消息,Ctrl侧可以订阅该消息以便感知节点上线。
  • 每个Node在下线时会发Node.Unregister消息,请求注销。
  • 为了防止Node崩溃时来不及发送注销消息,Node每20秒发一个Node.Update消息,该消息可以做为心跳保活消息使用。此外,该消息还携带节点当前的活动Channel数以及负载信息。Ctrl可以根据该信息向“负载最轻”的Node发送新任务。

注意:防止优先级反转。分布式系统的一个难点或者说一个误区就是无条件地往“负载最轻”的节点上发消息。假设我们做了一个自动外呼系统,有三个Ctrl和三个Node。在某一时间,三个Ctrl都发现node.1负载最轻,然后分别向它发送了10个外呼任务,node.1就会在同一时刻收到30个外呼任务,有可能立即成为“负载最重”的节点,甚至会过载。

总之,分布式系统从来都不是很简单就可以实现的,在实际使用时需要考虑各种边界情况。在实际使用时,简单的轮循算法基于就可以做到“足够好”,如果能配合一些反馈补偿机制,就能做到“更好”。

12 会议

会议使用XSwitch原生会议实现。必须将会议锁定到同一台XSwitch。

12.1 获取运行中的会议详情

  • commandconferenceInfo
  • data:数据
    • conferenceName:必填,会议室名称
    • showMembers:可选,布尔值,是否显示会议室成员,默认为否。

示例:

{
                            "jsonrpc":"2.0",
                            "id":"conference-request-1",
                            "method":"XNode.NativeJSAPI",
                            "params":{
                                "ctrl_uuid":"54d0d141-a748-406a-8a63-992f195789cc",
                                "data": {
                                    "command":"conferenceInfo",
                                    "data":{
                                        "conferenceName":"3000",
                                        "showMembers":true
                                    }
                                }
                            }
                        }
                        
                        {
                            "jsonrpc":  "2.0",
                            "id":   "conference",
                            "result":   {
                                "code": 200,
                                "message":  "OK",
                                "node_uuid":    "xcc-node-1",
                                "data": {
                                    "conference":   {
                                        "conference_name":  "3000",
                                        "member_count": 1,
                                        "ghost_count":  0,
                                        "rate": 8000,
                                        "run_time": 381,
                                        "conference_uuid":  "41d33e47-881e-483f-bef9-8174a8516f2e",
                                        "canvas_count": 0,
                                        "max_bw_in":    0,
                                        "force_bw_in":  0,
                                        "video_floor_packets":  0,
                                        "locked":   false,
                                        "destruct": false,
                                        "wait_mod": false,
                                        "audio_always": false,
                                        "running":  true,
                                        "answered": true,
                                        "enforce_min":  true,
                                        "bridge_to":    false,
                                        "dynamic":  true,
                                        "exit_sound":   true,
                                        "enter_sound":  true,
                                        "recording":    false,
                                        "video_bridge": false,
                                        "video_floor_only": false,
                                        "video_rfc4579":    false,
                                        "variables":    {
                                        },
                                        "members":  [{
                                                "type": "caller",
                                                "id":   2,
                                                "flags":    {
                                                    "can_hear": true,
                                                    "can_see":  true,
                                                    "can_speak":    true,
                                                    "hold": false,
                                                    "mute_detect":  false,
                                                    "talking":  true,
                                                    "has_video":    false,
                                                    "video_bridge": false,
                                                    "has_floor":    true,
                                                    "is_moderator": false,
                                                    "end_conference":   false
                                                },
                                                "uuid": "a3c2df89-b6c9-4fb3-a0a4-c1934b21f5a9",
                                                "caller_id_name":   "Seven Du",
                                                "caller_id_number": "1001",
                                                "join_time":    381,
                                                "last_talking": 0,
                                                "energy":   100,
                                                "volume_in":    0,
                                                "volume_out":   0,
                                                "output-volume":    0,
                                                "input-volume": 0
                                            }]
                                    }
                                }
                            }
                        }

12.2 执行会议控制

可以使用NativeAPI控制会议,如:

{
                            "jsonrpc": "2.0",
                            "id": "...",
                            "method": "XNode.NativeAPI",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid ... ",
                                "cmd": "conference",
                                "args": "list",
                            }
                        }

常用的命令有:(以下假定会议名称为3000

  • list:列出该节点上所有会议
  • json_list:同上,返回JSON字符串
  • xml_list:同上,返回XML字符串
  • 3000 list:仅列出名称为3000的会议
  • 3000 mute 1:对member_id1的成员静音
  • 3000 unmute 1:对member_id1的成员取消静音
  • 3000 mute all:将所有人静音
  • 3000 mute uuid=5bb13395-acc8-4dbc-9e90-2c6de0a27f98:将Channel UUID对应的成员静音
  • 更多命令可以在命令行上输入conference查看帮助

12.3 监听会议事件

可以监听原生的CUSTOM conference::maintenance事件。

13 WebRTC

XCC支持WebRTC呼叫。

  • 客户端通过Websocket连到XCtrl上,XCtrl可能会有多个实例。
  • 通过node_uuidctrl_uuid映射保证消息能正常路由。
  • 客户端“注册”时,仅连到XCtrl上,与Node没有关系。
  • 客户端发起呼叫时,发送verto.invite消息,此时,XCtrl查找可用节点,建立callIDnode_uuid的映射,并通过verto.trying消息通知客户端,客户端应该记住这个node_uuid并在后续的消息中发送。
  • callIDnode_uuid的映射关系将在通话结束后解除。后续的通话可能会路由到其它节点。

13.1 WebRTC做主叫

  • RTC客户端通过Websocket连接到XCtrl。XCtrl负责鉴权、分配FreeSWITCH资源等。
  • RTC客户端发送INVITE,XCtrl检查路由(找到Node节点)并发送到对应的FreeSWITCH,XCtrl会保持callIDnode_uuid的对应关系,以便路由后续的消息(如Bye)。
  • 当一个FreeSWITCH处理后,返回响应消息,携带node_uuid
  • 后续客户端消息都携带node_uuid,以便路由到原来的FreeSWITCH。
  • 如果客户端刷新重新,则可能连接到其它的XCtrl实例上,由于node_uuid是在客户端携带,因此能正确路由到相应的Node节点。

13.2 WebRTC做被叫

  • RTC客户端的Websocket连接鉴权通过后,XCtrl要存储注册信息。
  • 如果FreeSWITCH呼叫WebRTC用户,它将呼叫发送到cn.xswitch.ctrl.rtc上,XCtrl查询到用户对应的Websocket,然后呼叫用户。
  • 如果有多个XCtrl,则需要一种查询机制。

Verto支持如下呼叫字符串:

  • xrtc/profile_name/user@domain:FreeSWITCH将查找配置文件中名字为profile_name的profile,并将呼叫发送到profile配置中的xrtc-ctrl-subject节点上。由XCtrl负责后续寻址。
  • xrtc/user@domain:如果不带profile_name则默认为default profile。
  • {xcc_ctrl_uuid=xxxx}xrtc/profile_name/user@domain:FreeSWITCH将忽略profile中的xrtc-ctrl-subject,将消息发送到呼叫字符串携带的通道变量xcc_ctrl_uuid
  • xrtc/user@domain;ctrl_uuid=xxxx:XCtrl在发起呼叫时,首先查询用户Websocket对应的ctrl_uuid,然后生成呼叫字符串。
  • {ctrl_uuid=xxxx}xrtc/user@domain:同上。

13.3 WebRTC配置

配置文件名字为xcc-rtc.conf.xml,大部分配置与mod_verto模块的配置一致。

13.3.1 settings

  • debug 类型 integer 调试级别

13.3.2 profiles

13.3.2.1 profile

  • profile 属性 name 为profile的名称

参数(xcc独有的参数):

  • xrtc-ctrl-subject 类型 string XCtrl的nats通信topic,使用这个profile的verto消息都会发送到这个topic
  • media-timeout 类型 integer 媒体超时,单位为毫秒。 当通话时超过设定时间没有媒体通信的话freeswitch会将通话挂断

13.4 客户端协议

客户端建议还是使用现有的verto.js协议,稍加改动(往来消息中增加params.node_uuid)。

13.4.1 连接

如下代码,系统支持以下连接鉴权方式:

  • 用户名/密码:只需输入login/passwd参数
  • XUI统一登录:需要获取xuiToken作为xui_sessid,具体获取方法参见XUI相关说明
  • XCC统一登录:需要获取xccToken作为xcc_token参数传入。具体获取方法参见XCC系统相关说明
verto.connect({
                            login: username + "@" + domain,
                            passwd: password, // optional, when use xui_sessid or xcc_token
                            socketUrl: socketUrl,
                        
                            loginParams: {
                                xui_sessid: xuiToken, // optional
                                xcc_token: xccToken,  // optional
                            },
                        });

13.5 会议

Verto会议,对于同一个会议,统一路由到一个Node节点上。

第一个成员呼入时,Node将发送一个Event Channel消息,ConfMan收到后,会订阅相关的事件。需要保证所有后续消息都路由到同一个Node节点上。

消息如下:

  • confman-liveArray.3000-xswitch.cn@xswitch.cn:与会成员列表
  • confman-chat.3000-xswitch.cn@xswitch.cn:Chat Channel
  • confman-mod.3000-xswitch.cn@xswitch.cn:Moderator Channel,可以对会议进行控制

如果后续有人加入会议,则向Node发送bootstrap消息,Node收到后回复liveArray,同步所有已经参会的与会者列表。

14 TRTC

XSwitch支持与腾讯TRTC互联互通。

TRTC需要以下参数:

  • app_id:TRTC SDK App ID,字符串,不同AppID之间的数据不互通
  • room_id:房间ID,字符串,加入同一个room的成员可以媒体互通
  • user_id:用户ID,字符串,TRTC不支持同一个UserID (除非 SDKAppID 不同)在两个设备同时使用
  • user_sig:动态签名,详见 https://github.com/tencentyun/tls-sig-api 下面的README.md

TRTC在XSwitch内是一个Endpoint,与SIP类似,呼叫字符串格式为:

{trtc_user_id=$user_id,trtc_user_sig=$user_sig}/trtc/$app_id/$room/$dest_number

14.1 TRTC呼叫SIP

  • Android/IOS客户端加入room
  • TRTC加入room,在XSwitch中生成一个Channel
  • Bridge到SIP

有以下两种方式:

{
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.NativeAPI",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "cmd": "trtc",
                                "args": "call $app_id $user_id $user_sig $dest_number $cid_name $cid_number"
                            }
                        }

其中:

  • app_id:TRTC App ID,必选
  • user_id:TRTC 用户 ID,必选
  • user_sig:TRTC签名,必选
  • dest_nubmer:被叫号码,必选
  • cid_name:主叫名称,可选
  • cid_number:主叫号码,可选

该方法使用Native API发起呼叫,会到系统Dialplan中查找$dest_number,具体的Context可以在模块配置文件中配置。

此外,也可以直接使用Dial发起外呼(其中$相关的变量在真正使用时要以实际的值代替):

{
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.Dial",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "destination": {
                                    "global_params": {
                                    },
                                    "call_params": [{
                                        "uuid": "uuid",
                                        "dial_string": "trtc/$app_id/$room/$dest_number",
                                        "params": {
                                            "trtc_user_id": "$user_id",
                                            "trtc_user_sig": "$user_sig"
                                        }
                                    }]
                                }
                            }
                        }

其中:

  • dest_nubmer:被叫号码,如果为auto_answer,则channel会自动应答

呼通后,TRTC Channel将会自动接听,然后就可以调用XNode.Bridge去Bridge SIP侧的Channel了。

上述两种方式都需要先将TRTC SDK加入room,如果想省钱,可以先呼叫SIP,等SIP接通后再加入TRTC room,如果SIP侧呼不通,就无需加入TRTC room。呼叫流程如下:

  • XSwitch Dial SIP
  • XSwitch执行trtc App加入TRTC room,或通过Bridge加入TRTC room
  • 客户端SDK加入同一个room

14.2 SIP呼叫TRTC

SIP呼叫TRTC也有两种方式,当SIP Channel到来后,可以先Accept,然后Bridge到trtc/$app_id/$room/$dest_number即可,注意需要有trtc_user_idtrtc_user_sig参数。

另一种方式是直接调用一个NativeApp,如:

{
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.NativeApp",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "cmd": "trtc",
                                "args": "$appid $room $user_id $user_sig"
                            }
                        }

注意:不管从哪个方向呼,TRTC只有媒体层的协议支持,没有信令,因此,具体的信令由开发者自行实现,如:

  • 对于SIP来话,可以在Accept后,Bridge到TRTC room,并通过Push Notification通知移动客户端SDK加入某个room(具体先后顺序可以自行决定)。
  • 对于TRTC呼SIP的场景,移动客户端可以通过REST API或Websocket等方式通知Ctrl侧加个room并呼叫SIP。

14.3 会议

暂未实现。

在会议模式下,Linux SDK要以主播模式进入会议,FreeSWITCH侧如果有多个用户,则先在FreeSWITCH侧混流,然后以一路流的方式跟TRTC Cloud交互。

由于客户端会有多个主播加入,所以收到的多路流需要适当混流。

参见:https://cloud.tencent.com/document/product/647/35429

15 Agora

XSwitch支持与Agora互联互通。

Agora需要以下参数:

  • appid: Agora App ID,字符串,同一个ID下的媒体才有可能互通
  • token: App ID泄漏后后果严重,因此使用Token来代替App ID
  • channel: Agora Channel(频道),字符串,加入同一个Channel中的所有成员都可以媒体互通

Agora在XSwitch内也是一个Endpoint,与SIP类似,呼叫字符串格式为:agora/$appid-or-token/$channel/$dest_number

目前仅支持一个SIP Channel加入一个Agora Channel,不支持多个SIP端加入同一个Agora Channel(会议模式)。

在XSwitch中,也有Channel概念,一个Channel代表一个通道,也就是一路通话,与Agora概念区分,我们把XSwitch中的Channel叫通道,Agora中的Channel叫频道。

15.1 Agora呼叫SIP

  • Android/iOS客户端加入appid/channel
  • Agora加入appid/channel,在XSwitch中生成一个Channel(注意,与Agora Channel字符串不同)
  • Bridge到SIP

有以下两种方式:

{
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.NativeAPI",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "cmd": "agora",
                                "args": "call $key $channel $dest_number $cid_name $cid_number"
                            }
                        }

其中:

  • key:Agora App ID或Token,必选
  • channel:Agora Channel,必选
  • dest_nubmer:被叫号码,必选
  • cid_name:主叫名称,可选
  • cid_number:主叫号码,可选

该方法使用Native API发起呼叫,会到系统Dialplan中查找$dest_number,具体的Context可以在模块配置文件中配置。

此外,也可以直接使用Dial发起外呼:

{
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.Dial",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "destination": {
                                    "global_params": {
                                    },
                                    "call_params": [{
                                        "uuid": "uuid",
                                        "dial_string": "agora/$key/$channel/$dest_number",
                                    }]
                                }
                            }
                        }

其中:

  • dest_nubmer:被叫号码,如果指定为auto_answer,则Agora channel会自动应答

呼通后,Agora Channel将会自动接听,然后就可以调用XNode.Bridge去Bridge SIP侧的Channel了。

上述两种方式都需要先将Agora SDK加入Channel,如果想省钱,可以先呼叫SIP,等SIP接通后再加入Agora Channel,如果SIP侧呼不通,就无需加入Agora Channel。呼叫流程如下:

  • XSwitch Dial SIP
  • XSwitch执行agora App加入Agora Channel,或通过Bridge加入Agora Channel
  • 客户端SDK加入同一个Channel

15.2 SIP呼叫Agora

SIP呼叫Agora也有两种方式,当SIP Channel到来后,可以先Accept,然后Bridge到agora/$key/$channel/$dest_number即可。

另一种方式是直接调用一个NativeApp,如:

{
                            "jsonrpc": "2.0",
                            "id": "0",
                            "method": "XNode.NativeApp",
                            "params": {
                                "ctrl_uuid": "ctrl_uuid",
                                "cmd": "agora",
                                "args": "$key $channel"
                            }
                        }

注意:不管从哪个方向呼,Agora只有媒体层的协议支持,没有信令,因此,具体的信令由开发者自行实现,如:

  • 对于SIP来话,可以在Accept后,Bridge到Agora Channel,并通过Push Notification通知移动客户端SDK加入某个appid/channel(具体先后顺序可以自行决定)。
  • 对于Agora呼SIP的场景,移动客户端可以通过REST API或Websocket等方式通知Ctrl侧加个Agora Channel并呼叫SIP。

15.3 Agora Channel与agora App

在XSwitch中,一个Channel指一路通话,典型地,SIP呼Agora以及Agora呼SIP都有两个Channel组成,一个SIP Channel和一个Agora Channel,它们之间使用Bridge进行桥接,组成一个桥接的呼叫。

在XSwitch中,SIP与Agora通信的另一种实现方式是使用agora App,该App会执行join操作调用Agora Linux SDK加入一个Agora频道,但不会产生一个Agora通道,在XSwitch中,它是一个单腿呼叫。

15.4 会议

当有多个SIP Channel需要加入Agora Channel时,就形成一个会议,可以使用agora Application实现。

agora Application有一个全局的Agora Channel引用计数,多个SIP Channel可以加入同一个Agora Channel(增加一个uid)。

15.5 其它

具体示例可以参考xswitch/xcc-examples中的例子。

16 微信小程序

通过微信小程序呼叫

Todo.

17 开发指南

XSwitch XCC API功能强大,使用起来非常灵活,可以很简单的使用任何语言写出一些Demo IVR。但是,XCC API是基于RPC调用的,不管API设计多完善,由于网络和系统的复杂性,写出一个生产级别、高可靠的系统也不是一件容易的事。本章,我们就来看一下在面向生产环境开发应用程序时应该注意的问题。

17.1 我应该使用什么语言开发?

简单的回答是:使用你(以及你的团队)最擅长的语言。

XCC API通过NATS承载,NATS有各种语言的客户端库,因此,你几乎肯定可以找到你使用的语言的客户端库。

NATS支持同步调用和异步调用,XCC也是。NATS的API设计的很好,因此我们没有在NATS API上又包装一层XCC SDK,主要是因为不是太有必要,而且,我们不一定能包装好。如果只是进行简单封装,那么,你无法用到所有的参数和特性;如果彻底封装、精心检查每一个参数和返回值,那么,最终就会是包装的特别臃肿,而你的应用层还是免不了检查所有可能的返回值以打印特定的日志或者进行对应的处理。比如我们使用Play播放一个媒体文件,最简单的用法只有两个参数:当前Channel的UUID以及待播放文件的路径,但是当你播放TTS时,就需要传入更多的参数。哪些参数(比如TTS引擎)合法与不合法,可能只有到了XSwitch侧才能知道,或者在应用层(通过事先配置)知道,而处于中间Play函数却不容易知道。另外,对于Play的返回值,在XSwitch侧,通常有以下情况:

  • 成功:播放完成
  • 失败:文件不存在,或不运行的文件等
  • 中断:被API打断,或用户提前挂机

但在控制侧(你的应用程序侧),却又多了很多情况,如网络中断、消息超时、XSwitch过载或崩溃、NATS过载丢消息或崩溃等。当发生这些情况时,你的应用就会异常,你必须小心地检查各种异常以便清除缓存、打印相关的日志用于问题追踪等。当然,你写的程序也会崩溃,如果很不幸,你的程序崩溃了,那么,失去控制的XSwitch何去何从、你的程序恢复后又怎么收拾前面的烂摊子,也是需要考虑的问题。

所幸,NATS已经帮我们Cover了很多问题,NATS各种语言实现的客户端也都尽量发挥了相关的语言的特性,并能提供一致的调用逻辑。

因此,我们并没有再在NATS基础上包装一层客户端SDK。除了上述原因,还有就是我们并不是对所有语言都非常擅长,而且,不同的用户有不同的应用场景,即使对同一种语言也有不同的偏好,不同的调用习惯,因此,我们决定把这些自由留给开发者。

当然,我们本身也是开发者,在使用XCC API的过程中也逐渐形成了自己的Go语言SDK,我们也开源出来供大家参考,这些SDK我们会一直维护,因而大家也可以直接使用,并欢迎提出各种改进建议。关于该SDK会再后文提到。

17.2 同步调用

不管是本地函数调用还是RPC,同步调用都会比较简单。但是RPC由于需要经过网络传输的特殊性,实际上所有的RPC都是异步的,客户端发出一个消息,服务端收到并进行处理,然后将内容返回,客户端再根据服务端返回的数据返回给调用者。

NATS使用Reqeust机制做同步调用。同步订阅一个一次性的Subject,在发送请求的同时,带上这个Subject,对方在处理完后就可以直接将消息回复到这个Subject上,客户端收到回复后调用完成,在此之前,客户端就一直阻塞等待。如图:

同步调用同阻塞的,因而需要超时机制,一般在Request请求中都有一个timeout参数用于超时。在C语言中,是使用pthread/condition同步机制实现的,它会阻塞掉整个线程。还是以Play函数为例,为了方便调用者使用,它是阻塞的,只能等文件播放完成后才能返回。如果文件的长度是20秒,则必须保证timeout大于20才行,这样才能防止调用超时。如果对方提前挂机或Play被API人为中断,则该函数也会提前返回,状态码为410(Gone,表示Channel主体已不存在)或206(表示文件只播放了一部分)。

timeout在满足播放条件的情况下肯定是越短越好。为了能更精确的使用timeout,需要提前知道文件的播放长度。在播放时长不可预知的情况下,只能使用经验参数,保证不能太长,又不能太短。但无论如何,正常情况下,该函数或者返回服务端的结果,或者超时。

在网络发生问题、过载等导致消息丢失的情况下,发出的请求无法得到响应,调用者就会一直阻塞直到超时。这在timeout比较长的情况下,可能会导致过多的资源占用。所以使用同步机制要求网络比较可靠,XSwitch和NATS也不要过载(导致丢消息)。

同步调用的好处是可以很方便的写连续控制的代码,下面是一个调用方法的伪代码:

Play(file1)
                        Play(file2)
                        Play(file2)

但是,对方可能会提前挂机,Play也可能出错,所以,在真正应用中,都需要检查返回值:

code, message = Play(file1)
                        if code != 200
                            // 打印日志,错误处理代码 ...
                            return
                        end
                        code, message = Play(file2)
                        ...

由于同步调用是阻塞的,一般来说,都需要在独立的线程中调用。线程在不同的语言中有不同的表现方式,比如,在C语言中是线程,在Go语言中是协程(Goroutine),而在Javascript中,所有调用都是异步非阻塞的。

值一得的是,Javascript的NATS客户端库也提供了Request和Publish两种调用方法,区别是前者可以指定超时时间,并可以在收到结果后调用回调函数。

17.3 异步调用

异步调用使用起来更复杂一些,但对于调用者有更大的灵活性。异步调用通过使用NATS的Publish方法发送请求,Publish会立即返回而不等待执行结果,因此,要想获得结果,就必须订阅一个Subject用于接收返回值。同时。为了知道请求是哪个函数发出的,还需要“记住”请求的ID,如:


                        nats.sub('cn.xswitch.ctrl.ivr', onMessage) // 收到消息会执行onMessage回调函数

                        request_id = 0
                        requests = {} // 记录所有请求

                        function onMessage(msg) {
                            if (msg.method == 'Event.Channel') { // Channel 事件
                                if (state == 'START') { // 来话的第一个消息
                                    request_id++        // 请求id
                                    requests[request_id] = 'play file1'
                                    Publish(Play, request_id, file1) // 发送请求,播放file1
                                } else if (state == 'DESTROY') { // 挂机了
                                    delete(requests[request_id]) // 清理现场
                                }
                            } else if msg.result { 结果
                                state = requests[msg.result.id] // 哪个请求的返回结果

                                if (state == 'play file1') {
                                    // file1 播放完成
                                    request_id++        // 请求id
                                    requests[request_id] = 'play file2'
                                    Publish(Play, request_id, file2) // 发送请求,播放file2
                                } else if (state == 'play file2') {
                                    // file2播放完成...
                                }
                            }
                        }

从上述伪代码中可以看出,异步执行需要异步的等待执行结果,然后进行下一步。为了能匹配请求和返回值,还需要将请求id存起来。当然,挂机后需要清除缓存中的内容,或者,需要实现一种垃圾回收机制(以面对各种异步情况,如收不到DESTROY消息的情况,严谨的网络编程会认为网络永远会有不可靠的情况的)。其实,同步调用也是这么实现的,只不过将这些复杂性隐藏到了阻塞等待之后。

17.4 事件

XSwitch会发出事件通知,在JSON-RPC中,事件就是一个不带id的请求,且不需要回复。

在控制侧的事件接收通常是在独立的线程中执行的。控制侧可以根据接收到的事件执行一些控制,如收到语音识别的内容后打断放音操作。注意在收到事件后,不要阻塞当前的事件接收进程(比如不要执行阻塞的Play调用)。

在实际应用中,可以使用独立的线程或协程处理每一路通话,收到的事件也可以推到当前线程中处理,也可以在专门的线程中处理。在多线程(或协程)编程中,不可避免的要考虑对同一资源的竞争性访问,这一般可以通过Mutex或其它机制实现,具体的实现因程序语言而异(在Javascript中只有一个线程,因而不需要考虑这种情况)。

XSwitch有原生的事件,通常字段比较多,不建议使用。如果有可能,就建议使用Event.Channel事件。

17.4.1 Event.Channel

Channel事件,在Channel状态发生变化时发出。参见第节Channel State

Channel事件会发送到被AcceptAnswer接管的Controller上。

17.4.2 Event.CDR

CDR事件,在通话完毕后发出。每一个Channel都会有一个CDR事件,如果参与通话的是两条腿(alegbleg),则会有两个CDR事件,并分别有leg标志。CDR事件一般会在Event.Destroy之前发出。

CDR事件默认会送到与Event.Channel相同的Subject上,但也可以通过全局配置参数cdr-subject配置单独的Subject。

参见第节CDR相关说明

17.5 同步和异步调用相结合

在实际应用中,通常可以根据情况使用同步和异步结合使用。

17.6 Channel缓存

一般来说,简单的应用不需要对Channel信息进行缓存。todo

如果需要缓存,

17.7 gRPC和Protobuf

Protobuf是Google推出的消息序列化格式,配合gRPC使用更能发挥使用。XCC使用JSON而不是并没有使用Protobuf进行对象的序列化。这主要是因为Protobuf相对来说更重一些,而且,XCC传输的消息内容大部分是字符串,Protobuf的优势不是特别明显。另外,gPRC更重,且C语言没有官方的实现,C++语言的实现在XSwitch中表现不是很好(reload模块会有问题,一些网络资源无法释放干净),因此我们在XCC API中没有使用gRPC和Protobuf。

不过,XCC API有一个Protobuf描述(xctrl.proto,参见xswitch/xctrl),这主要是因为很多语言(如Go或Java)可以通过它转换成目标语言的对象,并序列化成JSON。该xctrl.proto是会一直维护的,可以在项目中使用。

XCtrl Proto参见:

17.8 JSON-RPC序列化和反序列化

XCC使用JSON-RPC消息承载。各种语言都有JSON-RPC的实现,但是,它们通常都耦合了传输层相关的代码实现(如HTTP),比较重。如果你需要自己实现JSON-RPC的消息序列化和反序列化,本节给出一些语言的参考。

17.8.1 Javascript

在Javascript语言中,JSON-RPC消息的序列化和反序列化都很简单,因为它几乎可以跟Javascript的对象一对一的转换,相当于Javascript中的一等公民,因而,无需特殊的处理。

值得一提的是,我们提倡在序列化JSON时使用“Pretty”格式,也就是有正常的缩进和换行,这样便于阅读和调试,对字节数的增加引起的影响也可以忽略不计。如果你特别在意在生产环境中的效率,那么可以使用条件编译技术仅在生产环境中使用“紧凑”格式。在Javascript中使用“Pretty”格式的将对象序列化的方法如下:

var str = JSON.stringify(obj, null, 2); // spacing level = 2

反序列化也很简单:

var rpc = JSON.parse(str);

17.8.2 Go

在Go语言中,习惯将JSON与Go语言的结构体相对应。由于JSON-RPC只是一个“信封”,在收到JSON-RPC消息时并不知道里面的内容应该对应哪个对象,因而一般需要两步或多步解析。以Event.Channel消息为例:

{
                            "jsonrpc": "2.0",
                            "method": "Event.Channel",
                            "params": {
                                "state": "START",
                                "...": "..."
                            }
                        }

当收到上述消息时,我们先解析“信封”,因为有method,所以是一个请求,没有id,说明是一个事件请求,不需要回复。根据method才知道params的结构,可以将params中的内容反序列化成ChannelEvent结构体对象。

import (
                            "encoding/json"
                        )
                        
                        type RPC struct {
                            Version string           `json:"jsonrpc"`
                            ID      *json.RawMessage `json:"id"`
                            Method  string           `json:"method"`
                            Params  *json.RawMessage `json:"params"`
                            Result  *json.RawMessage `json:"result,omitempty"`
                            Error   *json.RawMessage `json:"error,omitempty"`
                        }
                        
                        type ChannelEvent struct {
                            NodeUUID    string `json:"node_uuid"`
                            UUID        string `json:"uuid"`
                            State       string `json:"state"`
                            CidName     string `json:"cid_name"`
                            CidNumber   string `json:"cid_number"`
                            DestNumber  string `json:"dest_number"`
                        }
                        
                        var rpc RPC // 定义`rpc`变量为RPC类型
                        err := json.Unmarshal(msg.Data, &rpc) // 反序列化为RPC对象
                        if err != nil { // 错误处理
                        }
                        switch rpc.Method {
                        case "Event.Channel": // 根据`method`决定使用什么对象反序列化
                            {
                                var channelEvent ChannelEvent
                                err := json.Unmarshal(*(rpc.Params), &channelEvent)
                            }
                        }

在上述代码中,我们定义RPC为一个宽泛的类型,即可以接收JSON-RPC请求消息,也可以接收响应消息,它的的Paramsjson.RawMessage类型,因而它会保存原来JSON的内容而不深度解析,直到我们根据method知道params的类型后再进行解析。

下列代码是Ctrl侧发送应答请求的序列化代码示例:

type RPCRequest struct {
                            Version string `json:"jsonrpc"`
                            ID      string `json:"id"`
                            Method  string `json:"method"`
                            Params interface{} `json:"params"`
                        }
                        
                        type AnswerParams struct {
                            CtrlUUID string `json:"ctrl_uuid"`
                            UUID     string `json:"uuid"`
                        }
                        
                        rpc := RPCRequest{
                            Version: "2.0",
                            ID: "1",
                            Method: "XNode.Answer",
                            Params: AnswerParams{
                                CtrlUUID: "ctrl_uuid",
                                UUID:     "uuid",
                            },
                        }
                        
                        bytes, err := json.Marshal(rpc)

在上述代码中,RPCRequestRPC更具体,它只是一个请求,同时,它也比较宽泛,使用interface{}可以接受任何类型的参数,因而在构造请求结构体时我们可以传入AnswerParams类型的对象,序列化后的对象类型是byte[],可以直接通过网络函数发送,也可以转换成字符串打印输出。

由于Go语言允许在结构体定义时加入“json:”相关的注释,因而序列化后的字段名称可以根据情况指定。

除此之外,Go语言对Protobuf的支持非常完善,我们也提供xctrl.proto转换生成的Go语言对象和函数,这样就不需要自定义各种函数的结构体,使用起来就方便些。

当然,前面也提到,我们也有更深度的包装,做成了Go语言SDK,更方便使用,但也有更多的规矩和约束,这些约束适用于我们的代码架构,供大家参考。这些SDK会一直维护,如果也适合你使用,也可以直接拿来用。

17.8.3 Java

Java中有json-simplegson等可以直接序列化和反序列化JSON。与上一节中Go语言示例中对应的Java示例代码如下:

import org.json.simple.JSONObject;
                        import org.json.simple.parser.JSONParser;
                        
                        String rpc = new String(json_request_string, StandardCharsets.UTF_8);
                        JSONObject m = (JSONObject) parser.parse(rpc);  // 将JSON字符串解析成Java对象
                        String method = (String) m.get("method");
                        JSONObject params = (JSONObject)m.get("params");
                        String state = (String) params.get("state");
                        
                        if (method != null && method.equals("Event.Channel") &&
                            state != null && state.equals("START")) { // 新来话事件
                            String node_uuid = (String) params.get("node_uuid");
                            // 构造应答请求
                            JSONObject rpc = new JSONObject();
                            rpc.put("jsonrpc", "2.0");
                            rpc.put("id", "test-id");
                            rpc.put("method", "XNode.Answer");
                            JSONObject p = new JSONObject();
                            p.put("uuid", (String) params.get("uuid"));
                            p.put("ctrl_uuid", "ctrl_uuid");
                            rpc.put("params", p);
                            StringWriter request = new StringWriter();
                            rpc.writeJSONString(request);
                            System.out.println(request);
                            // 通过NATS发送请求 ...
                        }

从上述代码可以看出,直接操作JSON通用对象的代码也不复杂,但与序列化成具体的对象类相比,显得不太直观,而且如果JSON中的字符比较多的情况下,代码就比较冗长了。

Java中不支持类似Go语言中的json.RawMessageinterface{}机制,因而使用起来要复杂些。gson支持对象的序列化和反序列化,可以配合使用。xctrl.proto也可以直接生成Java类(Xctrl.java),只是Protobuf对JSON的支持没有对Protobuf原生协议支持的好。下面是使用gson和Protobuf的示例:

import com.google.protobuf.util.JsonFormat;
                        import com.google.protobuf.*;
                        import com.google.gson.JsonElement;
                        import com.google.gson.JsonObject;
                        import com.google.gson.JsonParser;
                        import xctrl.Xctrl.AcceptRequest;
                        import xctrl.Xctrl.Request;
                        import xctrl.Xctrl.PlayRequest;
                        import xctrl.Xctrl.Media;
                        import xctrl.Xctrl.ChannelEvent;
                        import xctrl.Rpc.*;
                        
                        
                        private static RPCRequest.Builder rpc(String method, String id) {
                            return RPCRequest.newBuilder()
                                .setJsonrpc("2.0")
                                .setMethod(method)
                                .setId(id);
                        }
                        
                        // 请求字符串
                        String event = new String(json_request_string, StandardCharsets.UTF_8);
                        System.out.println(event);
                        JsonElement e = parser.parse(event);  // 将请求字符串解析成`gson`对象
                        JsonObject root = e.getAsJsonObject();// 找到根对象
                        String method = root.get("method").getAsString(); // 找到`method`
                        JsonObject params = root.get("params").getAsJsonObject(); // 找到`params`
                        if (method.equals("Event.Channel")) {
                            // 将params重新变成string,注意这一点与Go语言中不同
                            // gson中应该有方法直接将gson对象直接转换成Protobuf对像,但未找到怎么用
                            String sparams = params.toString();
                            ChannelEvent.Builder cevent = ChannelEvent.newBuilder();
                            // 反序列化成`ChannelEvent`类
                            JsonFormat.parser().ignoringUnknownFields().merge(sparams, cevent);
                            if (cevent.getState().equals("START")) { // 来话请求
                                // 下列代码应试在新线程中执行(因为是阻塞的),但简单起见,我们直接写在这里
                                JsonFormat.Printer printer = JsonFormat.printer().preservingProtoFieldNames();
                                String node_uuid = cevent.getNodeUuid();
                        
                                AcceptRequest accept = AcceptRequest.newBuilder()
                                    .setUuid(params.get("uuid").getAsString())
                                    .setCtrlUuid("ctrl_uuid")
                                    .build();
                        
                                RPCRequest xrpc = rpc("XNode.Accept", "0") // 创建RPC请求
                                    .setParams(Any.pack(accept)) // params被定义为Protobuf的Any类型
                                    .build();
                        
                                // Any类型必须提供一个TypeRegistry才能正常序列化,它会生成一个额外的`@type`字段
                                TypeRegistry registry = TypeRegistry.newBuilder()
                                    .add(accept.getDescriptorForType()).build();
                                String rpc_accept = printer.usingTypeRegistry(registry).print(xrpc);
                                System.out.println(rpc_accept);
                                // 通过NATS发送请求 ...
                            }
                        }

在上述示例中,我们通过JsonFormat将对像序列化,它是Protobuf中标准的序列化方法,但对于Any类型的请求会生成额外的@type字段,理论上对方通过@type能正确的反序列化。但XSwitch不支持@type,因而生成的消息无法在Ctrl侧用Java正确的反序列化,因此上述代码中我们仍然使用了gson

我们相信Java语言如此成熟且应用广泛,一定有更好的方法。

17.9 Subject及各种ID及UUID详解

使用XCC开发时,会遇到好多Subject、ID及UUID,虽然我们在本文档中给出了解释,但是初学者还是容易混,因此,在此我们再详细解释一下。

17.9.1 RPC id

RPC中的id是JSON-RPC要求的,它代表一个请求。XSwitch要求该id必须是一个字符串类型。XSwitch本身不关心该ID,会在Response中原样返回。所以,该id可以是任意字符串。但是,为了便于跟踪消息和排错,建议使用真正的UUID字符串,每个请求一个,保证唯一。

对于NATS,如果使用异步消息(Publish而不是Request),则需要根据JSON-RPC的语义匹配返回结果。

17.9.2 Node UUID

每个XSwitch媒体节点又称为一个Node,因此有一个唯一的Node UUID,在事件消息中写为node_uuid。该node_uuid必须加上cn.xswitch.node前缀,才是媒体节点订阅的Subject。一般来说,根据业务属性,媒体节点也会以队列方式订阅一些其它的业务Subject,如cn.xswitch.node.ivrcn.xswitch.node.test等,这样在有很多媒体节点的情况下便于按业务分组。

17.9.3 Ctrl UUID

与Node UUID类似,每一个Controller都需要有一个唯一的Ctrl UUID,用于与Node UUID建立虚拟对应关系。Ctrl UUID在JSON消息中以ctrl_uuid表示,每个Controller对需要加上cn.xswitch.ctrl.前缀订阅自己的主题,如:cn.xswitch.ctrl.f76f6931-38c1-123b-4786-0242ac120003

多个Ctrl可以以Queue订阅的方式订阅一个业务Subject,如cn.xswitch.ctrl、或cn.xswitch.ctrl.ivr等,多个Ctrl之间会竞争的接收发到这个Subject上的消息。

在一些高级应用中,同一个Ctroller也可以订阅很多UUID。如,在批量外呼应用中,可以对每路通话产生一个Channel UUID,进而订阅cn.xswitch.ctrl.$channel_uuid,当呼叫完成后取消订阅。

17.9.4 Channel UUID

每个呼叫(Channel)都有一个UUID,唯一表示这路呼叫。对于来话,Channel UUID是由XNode产生的。对于去话,Channel UUID可以由Controller产生,或者由XNode产生。

17.9.5 XNode侧的Subject

XNode侧的Subject必须以cn.xswitch.node前缀开头。对于从Controller侧发起的外呼或者命令(如status命令)而言,XNode属于服务的一方,Controller属于客户端,因而Controller会向cn.xswitch.node发Request。

17.9.6 XCtrl侧的Subject

Controller侧的Subject必须以cn.xswitch.ctrl前缀开头。多个Controller可以以Queue的方式竞争订阅cn.xswitch.ctrl主题。对于从XNode侧的来话而言,XNode是客户端,XCtrl是服务器。但是,在XCtrl接收到来话消息以后,它要命令XNode做事情,如AnswerPlay等,则XCtrl又是客户端,而XNode是服务器,此时所有命令应该发到cn.xswitch.node.$node_uuid上。

17.10 示例

请参考 https://git.xswitch.cn/xswitch/xcc-examples 中的相关示例,示例大部分以Node.js(Javascript)语言提供,因为Node.js可以比较方便的描述JSON,做前后端的也都熟悉。

示例中也有Go、Java等语言的参考,其中的README也能提供更多的信息。

18 附录

18.1 常见问题解答(FAQ)

  • Q:XSwitch是否可以支持单机版使用?

  • A:带XUI的XSwitch本身设计是简单易用,支持单机版单租户

  • Q:XSwitch是否支持集群?

  • A:支持,只是带XUI的版本在界面上有些限制。

  • Q:XSwitch是否支持Lua?

  • A:XSWitch支持原生FreeSWITCH支持的所有能力,包括Lua。事实上,XSwitch的后台的Rest API全部使用Lua开发的。

  • Q:XSwitch是否支持ESL?

  • A:XSWitch支持原生FreeSWITCH支持的所有能力,包括ESL。

  • Q:XSwitch底层使用了XML动态绑定,是否支持还其它的绑定?

  • A:XSwitch同时支持多种XML绑定,但是顺序不好控制,必要时,可以禁用XSwitch内置的绑定。

  • Q:在使用XCC API进行开发时,最好使用哪种语言?

  • A:我们推荐使用Go语言,但实际上你应该使用你最擅长的语言,详见第节我该使用什么语言开发

  • Q:为什么不提供Java SDK?

  • A:每个人的应用场景和调用习惯不同,我们相信直接使用NATS接口能最大限度的给你自由,详见第节我该使用什么语言开发。。

18.2 呼叫字符串

FreeSWITCH原生的呼叫字符串。

18.2.1 本地用户

注册到FreeSWITCH,FreeSWITCH可以直接呼叫。

user/分机号

18.2.2 通过IP呼出

直接通过IP白名单方式对接。对方需要对FreeSWITCH开通IP白名单。具体的IP和端口号跟FreeSWITCH侧的SIP配置有关。

sofia/public/号码@ip:port

如果使用TCP或TLS链路,可以添加参数

sofia/public/号码@ip:port;transport=tcp

18.2.3 通过网关呼出

在FreeSWITCH侧可以配置一个网关,网关可以以注册或非注册方式与对端对接。对端需要提供至少以下信息:

  • realm: 域名或IP地址
  • username: 用户名
  • password: 密码

具体网关名称需要在FreeSWITCH侧配置。

格式:

sofia/gateway/网关名称、被叫号码

18.2.4 示例

  • user/1000 呼叫本地用户
  • user/1001
  • sofia/public/1860535xxxx@192.168.1.1 默认端口为5060
  • sofia/public/1860535xxxx@192.168.1.1:2345 指定端口
  • sofia/public/1860535xxxx@192.168.1.1:2345;transport=tcp TCP
  • sofia/public/1860535xxxx@192.168.1.1:2345;transport=tls TLS

18.3 TTS和ASR

TTS和ASR是人工智能必要的组成部分。XCC集成了市面上大部分的公有云接口。

18.4 TTS参数

  • engine:引擎名称。跟实现有关,目前可用的有alihuaweibaiduifly等。

  • voice:嗓音,跟具体引擎有关,可传入default,或查找相关引擎提供的嗓音值。

  • 阿里Voice相关配置:https://help.aliyun.com/document_detail/84435.html

  • 华为相关参数配置:https://support.huaweicloud.com/api-sis/sis_03_0111.html

18.5 ASR参数

ASR检测一个很关键的部分是VAD(Voice Activity Detection),即首先要检测是否有人讲话,再去识别内容。以下图为例,横向为时间轴t,纵向为语音波形。其中thresh为语音检测阈值,即小于该值认为没有声音。该值在公有云连续识别时一般不用设置。如果使用XSwitch原生的VAD,则需要设置。

             _-_       ..         ~~.
                        thresh    _-/   \_-^-_/  __      /   \                  thresh
                        ---------/                 \    /     \ ----------------------
                                /                   \__/       \
                        ---------------------------|----------|----------|---------> t
                        no-input|                  |          |          |
                                |~~|               |          |          |
                        voice-ms_/ |Begin Speaking |          |          |
                                   |Speaking ... Partial ... Partial ... |End Speaking
                                |  |               |silence-ms|silence-ms|End Speaking
                                |  |
                                |  | speech-timeout      |End Speaking
  • 如果在no-input-timeout内用户一直没有讲话,则会返回no-input
  • 当开始检测到语音时,如果连续voice-ms时间检测到语音活动(~~波浪线部分),则为认讲话开始。
  • 在语音检测过程中可能会返回中间结果(Partial)。中间结果是不稳定的,当检测到更多内容时,ASR引擎会根据语义理解等进行修正。
  • 当语音强度低于阈值时,启动silence-ms计数,超时后认为讲话结束。如果在超时前又检测到强度回升,则继续检测。
  • 直到检测到语音强度低于阈值越过silence-ms时,认为讲话结束,返回最终结果。

18.6 打断

默认情况下,ASR返回结果会“打断”当前的放音(如果还没有播放完的话),nobreak参数可以控制是否打断。

有时候,用户程序希望能根据识别的结果进行条件打断,可以使用offset参数实现。识别结果中返回的offset为当前播放的文件被打断的位置。下一次调用时使用该offset可以从该offset位置开始播放。目前仅对文件类型的播放有效,尚不支持TTS。

18.6.1 通用参数

以下参数在所有引擎中通用。注意,这些是引擎原始参数,值为字符串类型,如在DetectSpeech接口中应该放到params中。

  • no-input-timeout:毫秒,未检测到语音超时,通常为2000-10000
  • speech-timeout:毫秒,讲话超时。从检测到讲话开始算,或者从放完音开始算。通常大于2000,小于60000
  • voice-ms:毫秒,连续检测到语音时长,通常为20-100
  • silence-ms:毫秒,尾部静音时长,通常为800-2000
  • engine:引擎名称
  • partial:是否产生中间结果
  • add-punct:是否加标点,字符串truefalse
  • vad-mode:VAD模式,VENDORNATIVE0 ~ 3等。

18.6.2 ali相关参数

  • segment-recording-prefix:分段录音前缀(路径),如果不设置则禁用分段录音。
  • disable-engine-data:不发送ASR引擎返回的原始数据。
  • 文档:https://help.aliyun.com/document_detail/84435.html

18.6.3 huawei相关参数

文档:https://support.huaweicloud.com/api-sis/sis_03_0111.html

18.7 XCC Detect

原生FreeSWITCH实现的接口中,每个检测都需要起单独的线程,而且通过Event方式传递文本的消息,不利于控制。另外,原生的FreeSWITCH使用轮循、忙等待的方式检查有没有识别结果,效率不高,状态机特别复杂。

XCC重新实现了语言检测接口,基本上还是使用原来的模块接口,但不再需要忙等待方式提供检测结果,而是直接将检测到的结果推到当前Session的消息处理队列中,实现更高效。

新接口接受JSON格式的参数输入,比FreeSWITCH原生接口更清晰、方便。

XCC接口与原来的接口兼容,由于历史原因,在mod_huaweimod_ali中还保留了FreeSWITCH原生接口的状态机。新的模块中无需再实现这一复杂的状态机。有利于简化代码,提高效率。

18.8 媒体文件

在使用Play播放文件时,data参数支持如下文件类型:

18.8.1 音视频文件

  • 绝对路径:如/tmp/test.wav
  • HTTP文件:如http://example.com/test.wav,支持httphttps
  • 静音:如:silence_stream://3000,其中3000为表单的毫秒数。
  • 铃音:TGML约定的铃音,如tone_stream://%(1000,4000,450)为中国回铃音。详情参见: https://freeswitch.org/confluence/display/FREESWITCH/TGML
  • 多文件拼接:使用英文!分隔(文件名中不能有!),如file_string:///tmp/1.wav!/tmp/2.wav

系统支持以下文件类型:

  • .wav
  • .mp3
  • .mp4
  • .mkv
  • .m4a
  • .mov
  • .webm

支持以下协议:

  • http
  • rtmp
  • rtmps
  • rtsp

18.8.2 图片文件

可以把PNG文件当成视频文件用。如:/tmp/test.png。文件路径前可以加参数,如{png_ms=10000}/tmp/test.png表示播放10秒。支持的参数列表如下:

  • png_ms:时长,毫秒
  • audio_file:音频文件路径
  • png_fps:帧率,默认为5
  • alpha:是否支持Alpha通道
  • text:文本,可以以TTS方式播放,但需要提供下列参数
  • tts_engine:TTS引擎
  • tts_voice:TTS噪音

除PNG外,还支持以下扩展名的文件:

  • *.jpg
  • *.jpeg
  • *.bmp

18.9 CDR相关说明

18.9.1 默认XCC-CDR字段说明

话单字段 中文说明
caller_id_name 主叫名称
start_stamp 开始时间
billsec 计费时长
account_code 计费号码
network_addr 网络地址
abs_path 录音文件绝对路径
answer_stamp 应答时间
sip_hangup_disposition SIP相关
context 呼叫源
duration 总时长
end_stamp 结束时间
uuid 通话UUID
bleg_uuid B腿UUID
direction 方向,inbound为向内,outbound向外
destination_number 被叫号码
network_port 网络端口
realm
caller_id_number 主叫号码
hangup_cause 挂机原因

18.9.2 常用挂机原因说明 3

挂机原因表
挂机原因 中文说明
ORIGINATOR_CANCEL 主叫挂机
NORMAL_CLEARING 正常释放
NORMAL_TEMPORARY_FAILURE 临时故障
WRONG_CALL_STATE 呼叫状态异常
USER_BUSY 用户忙
LOSE_RACE 别处应答
MEDIA_TIMEOUT 媒体超时
CALL_REJECTED 拒绝呼叫
UNALLOCATED_NUMBER 空号
NO_ROUTE_DESTINATION 无法路由
NO_USER_RESPONSE 久叫不应
NO_ANSWER 无应答
NORMAL_UNSPECIFIED 未定义
NETWORK_OUT_OF_ORDER 网络异常
RECOVERY_ON_TIMER_EXPIRE 呼叫超时
USER_NOT_REGISTERED 用户未注册
SUBSCRIBER_ABSENT 用户缺席
GATEWAY_DOWN 网关故障
MANDATORY_IE_MISSING SIP消息不全
SYSTEM_SHUTDOWN 系统关机
INCOMPATIBLE_DESTINATION 目的地不兼容
EXCHANGE_ROUTING_ERROR 交换路由错误
MANAGER_REQUEST 强制挂机
DESTINATION_OUT_OF_ORDER 目的地异常
SERVICE_NOT_IMPLEMENTED 服务未实现

18.10 参考资料


  1. 参见 https://nats.io/↩︎

  2. ANI II (OLI - Originating Line Information),参见:http://www.nanpa.com/number_resource_info/ani_ii_digits.html↩︎

  3. https://freeswitch.org/confluence/display/FREESWITCH/Hangup+Cause+Code+Table↩︎