首页 / 资讯中心 / 文章详情

FreeSWITCH ASR模块开发实战:C++实现实时语音识别与事件发布

FreeSWITCH ASR模块开发实战:C++实现实时语音识别与事件发布 ★ FEATURED ARTICLE
简介FreeSWITCH-ASR 是一个面向 FreeSWITCH 开发者的开源语音识别对接模块专为希望了解 ASR 接入方式又不愿购买商业方案的 C 开发者提供一套可运行的参考实现。首个版本实现了两者的直接对接识别结果通过 ESL 输出包内附带编译好的动态运行库及依赖文件支持 x64 系统直接部署同时保留完整 cpp 源码便于自行编译、修改与本地调试。资源共 10 个文件压缩包约 714KB主要包含 so 动态库、cpp 源码、README 说明、配置文本、工程文件及演示图片动态库用于模块加载源码用于原理研究说明文档帮助快速上手整体结构精简实用。项目自 2017 年 12 月提交第一版代码以来已有 922 人学习下载可对照源码理解模块加载、结果回调与 FreeSWITCH 对接的整体流程并借助现成工程与运行库快速搭建实验环境少走编译弯路。1. FreeSWITCH ASR应用一通电话里的语音识别模块长什么样做呼叫中心的人应该都有过这种体会IVR流程里的地址、订单号只能靠按键输入用户明明在说“我家住在3号楼2单元”系统却一个字都听不懂。这个项目要解决的正是这件事——在 FreeSWITCH 里挂一个 ASR 模块把通话中用户的语音实时转成文本让流程引擎可以按语义走分支而不是死等 DTMF。核心是用 C 写一个 FreeSWITCH 的加载模块通过媒体监听拿到每一帧音频转给识别引擎再把结果以事件形式抛给呼叫流程。适合两类人一类是在搞全渠道客服平台的架构师想搞清楚语音识别到底怎么嵌进软交换另一类是刚接手 FreeSWITCH 源码编译的 C 开发者想找一个不依赖外部商业 SDK 的模块范本。2. 整体架构与模块设计从 SIP 信令到 ASR 结果的完整链路2.1 ASR 模块在 FreeSWITCH 里的挂载方式FreeSWITCH 本身是 C 写的模块体系通过switch_loadable_module这层结构对外暴露能力。我接触过的 ASR 模块几乎都是同时注册三样东西应用接口application interface、媒体监听media bug和事件发布。应用接口负责被人呼叫比如拨号计划里喊一声asr_start模块就在那个 session 上开始收音频媒体监听负责在 RTP 收发链路里截一份音频副本出来不影响正常通话事件发布则负责把识别结果广播给外面监听的人。这三个能力合起来ASR 模块在整个 FreeSWITCH 里的位置就很清晰了它不碰 SIP 信令不做注册管理只管“从媒体流里拿音频 → 送给识别引擎 → 把文本结果发出来”。换句话说语音识别是寄生在通话媒体链路上的外挂能力不是交换核心的一部分。这个定位决定了它的挂载方式不可能像 dialplan 那样在呼叫路由里直接 return而是要拆成“应用接口 bug 监听 事件”三件套。单就目录归属来说常见做法是在src/mod/asr下新建一个独立子目录保留自己的Makefile和主树一起参与make mod_all。这样既能跟主分支的编译链走又不至于把 ASR 业务代码塞进核心交换逻辑里。下面是一份精简版的目录结构我用过的一个项目基本长这样src/mod/asr/mod_asr/ ├── mod_asr.c # FreeSWITCH 模块入口与接口注册 ├── mod_asr.h # 公共头文件和数据结构定义 ├── asr_engine.cpp # C 实现的识别引擎封装喊外部引擎或 HTTP 服务 ├── asr_engine.h ├── asr_buffer.cpp # 音频帧缓冲队列、采样率适配逻辑 ├── asr_event.cpp # 识别结果的事件组装与发布 ├── Makefile # mod_asr 专用编译脚本 └── asr.conf.example.xml # 示例配置引擎地址、语言、采样率、超时等2.2 模块接口与识别引擎的通讯设计接口通讯设计上我一般把 ASR 模块拆成两层。上层是 FreeSWITCH 的 C 接口层——模块加载函数、应用接口的on_exec回调、media bug 的frame回调下层是 C 的引擎封装层——负责把上层递进来的原始音频帧做缓冲、切分、配置最终通过统一接口发给识别引擎。这样的好处是 C 和 C 的边界能干净分离。FreeSWITCH 给外界的头文件是全 C 风格的但模块内部要管理线程池、消息队列、状态机时C 的std::thread和std::queue明显比手写 C 链表省事。常见的做法是用extern C包裹上层的 C 回调函数再在回调里转调 C 类的方法避免两套编译体系互相污染。下面这段代码示意了extern C的边界怎么切。// mod_asr_cbridge.cpp // C 与 C 的桥接层FreeSWITCH 回调统一在这里做一层转发 extern C { #include switch.h } #include asr_engine.h static switch_status_t on_frame_callback(switch_media_bug_t *bug, switch_media_bug_frame_t *frame) { // 这里的 bug 指针就是 media bug 的唯一标识 AsrSession *session static_castAsrSession *( switch_media_bug_get_user_data(bug)); if (session nullptr) { return SWITCH_STATUS_FALSE; } // 转发给 C 侧处理音频真正识别逻辑不暴露给 C 层 return session-HandleFrame(frame) ? SWITCH_STATUS_SUCCESS : SWITCH_STATUS_FALSE; }这段代码里有几个参数值得留意。switch_media_bug_t *bug是 FreeSWITCH 内部对媒体监听的抽象每个 bug 都绑定一个通话 session不能用全局变量去区分“这次是哪个电话”switch_media_bug_frame_t *frame里带的是裸 PCM 数据格式在创建 bug 时约定好switch_media_bug_get_user_data是在 bug 创建时挂上的自定义指针这里才是我们自己的会话对象。识别的最终结果会通过事件发出去外部程序监听 FreeSWITCH 事件即可不用在模块内部另开 socket。2.3 为什么偏向 C 而不是纯 C有人问过FreeSWITCH 的模块 API 全是 C为什么识别引擎不干脆也用 C 写。我的理解是ASR 的核心逻辑里有两个东西用 C 写特别难受一个是状态迁移每个会话要从IDLE → 收集中 → 识别中 → 结束流转C 的switch套case写多了就成意大利面另一个是并发缓冲音频帧到的频率是每秒 8000 或 16000 个采样点必然要有一个生产者消费者模型C 的mutex和condition_variable组合比手工信号量好维护得多。这也是这个模块最值得参考的地方它把 FreeSWITCH 的 C 世界和 C 的业务世界通过一层桥接结构衔接起来两边互不越界。接口层对 FreeSWITCH 的依赖缩到最小业务层保持相对纯净后面要换识别引擎、加缓存策略都不需要动 FreeSWITCH 主框架的代码。3. 核心代码拆解C 实现 ASR 回调的完整链路3.1 模块入口注册与应用接口一个模块能不能被 FreeSWITCH 加载取决于它是否正确导出了SWITCH_MODULE_LOAD_FUNCTION。ASR 模块在这个函数里除了做常规初始化还要注册一个应用接口名字通常是asr_start或aisound_asr。以asr_start为例注册过程如下。// mod_asr.c 简化版 #include switch.h SWITCH_MODULE_LOAD_FUNCTION(mod_asr_load) { switch_application_interface_t *app_interface; // 注册应用接口调用时在拨号计划里写 asr_start 即可 SWITCH_ADD_APPLICATION(app_interface, asr_start, asr_exec, ASR recognize start, Start ASR recognition for a channel, SAF_NONE); // 读取配置文件初始化引擎封装 asr_global_init(); return SWITCH_STATUS_SUCCESS; } SWITCH_MODULE_DEFINITION(mod_asr, mod_asr_load, NULL, NULL);SWITCH_ADD_APPLICATION是宏往模块表里挂了一个名字叫asr_start的应用。第四个参数是短描述第五个是长描述最后那个标志位SAF_NONE表示这个应用不需要 FreeSWITCH 在执行前对 channel 加锁因为音频识别场景里媒体 bug 本身已经保证了流的可用性。asr_exec才是真正的入口它拿到 session 后要做几件事初始化识别参数、创建媒体 bug、把 session 和 bug 的用户数据绑定起来。这里要提醒的是asr_start这种阻塞式应用接口会卡住 dialplan 的执行直到识别结束或者asr_stop被呼叫。所以设计上识别过程通常会做成异步——asr_exec里只负责启动 bug 和挂回调真正识别完的结果通过事件发出来。我看到不少项目把“阻塞式启动”和“异步回传”混在一起结果 dialplan 卡死呼叫释放不了这是头号翻车现场。3.2 媒体流采集把来电音频截出来媒体 bug 的回调是 ASR 模块里最关键的一段。FreeSWITCH 每播放或录制一帧媒体都会经过注册的回调函数我们在这里拿到底层的 PCM 数据转发给识别引擎。识别引擎一般要求音频采样率 8k 或 16k 的单声道 16bit PCM所以这里要做一个格式适配。// asr_engine.cpp 内媒体帧转发逻辑 switch_status_t AsrSession::HandleFrame(switch_media_bug_frame_t *frame) { // frame-data 就是原始 PCM 数据长度由 frame-datalen 决定 const char *pcm frame-data; size_t len frame-datalen; // 耳机口最常见的坑是采样率不匹配这里做重采样适配 if (frame-samplerate ! engine_-GetInputSampleRate()) { pcm resampler_.Resample(pcm, len, frame-samplerate, engine_-GetInputSampleRate()); len resampler_.GetOutputSize(); } // 缓冲队列最大攒 320ms 音频防止内存被慢消费拖爆 queue_.Push(pcm, len); if (queue_.GetDurationInMs() 320) { // 达到一个识别周期把缓冲切出去做引擎识别 SubmitToEngine(); } return SWITCH_STATUS_SUCCESS; }frame-samplerate是这一帧媒体在 FreeSWITCH 内部实际使用的采样率和通话协商的编码无关常见取值是 8000、16000 或 48000。resampler_里我是用 SpeexDSP 做的重采样因为它稳定且只依赖一个库FreeSWITCH 自身编译时也会带上不增加额外负担。queue_.GetDurationInMs()的判断是一种简单的时间累积策略用单次呼叫的固定时长切分音频避免每帧都去启动引擎引发的抖动。3.3 识别结果的回传与事件发布识别引擎完成后返回文本模块要把它发出去。推荐的通道是 FreeSWITCH 事件系统这样不会阻塞呼叫而且任何语言写的服务——只要连接 fs_cli 或 ESL——都能收到。下面的代码展示了事件怎么组装// asr_event.cpp void AsrSession::PublishResult(const char *text, int confidence) { switch_event_t *event; if (switch_event_create(event, SWITCH_EVENT_CUSTOM) ! SWITCH_STATUS_SUCCESS) { return; } // CUSTOM 事件必须指定子类名外部监听时按这个名字过滤 switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, Event-Subclass, asr::result); switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, Asr-Result, text); switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, Asr-Confidence, std::to_string(confidence).c_str()); switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, Channel-Unique-ID, channel_uuid_.c_str()); switch_event_fire(event); }事件携带的字段有讲究。Event-Subclass必须存在否则监听方按asr::result订阅不到Channel-Unique-ID用来让业务方把识别结果和具体某一路呼叫对上号Asr-Confidence是置信度取值范围依赖引擎有的是 0-1 浮点有的是 0-100 整数模块在桥接层已经把公司内部用的多种引擎统一成 0-100 整数了。所以外部消费者不用关心底层用的是哪家引擎。事件发布完成后还要顺手检查一下这个结果是不是会话的最终结果。是最终结果就把 media bug 关掉、清理缓冲否则就继续躺着等下一轮。状态迁移这一块最好搞一个小状态机我是用枚举 switch (state_)来处理的避免出现“已结束的会话还在吐识别文本”这种鬼事。4. 编译部署与运行把 ASR 模块跑起来4.1 编译环境准备FreeSWITCH 的 C 模块编译依赖主树的结构。前提是你本地已经有 FreeSWITCH 的源码树并且至少成功编译过一次核心因为模块编译需要它生成的各种头文件和目标文件。环境准备好了之后编译 ASR 模块通常不是直接make那么简单要先把模块目录作为独立目标编译进去。我在一个模拟项目里常用的编译步骤是# 进入 FreeSWITCH 源码根目录 cd /opt/freeswitch/src # 先把模块列表里加上 mod_asr ./configure --enable-mod-asr --disable-core-odbc-support # 只编译 ASR 模块不碰其他模块 make mod_asr # 安装到模块目录 make mod_asr-install--enable-mod-asr是让 configure 把 mod_asr 纳入编译列表如果没写这个后面make mod_asr会提示找不到目标。--disable-core-odbc-support与 ASR 无关只是我本地编译时习惯去掉没用的东西减少依赖拉取你如果已经有现成的configure缓存可以不加这个参数。依赖这一层容易踩坑。模块用了 SpeexDSP 做重采样那 FreeSWITCH 主树编译时就必须能看到 speex 的头文件。不同的 Linux 发行版包名不一样有的叫libspeex-dev有的在libspeexdsp-dev里建议确认一下speex_resampler.h能不能被找到否则编译期会报一个很隐蔽的speex_resampler_create 未声明的错。常见做法是在模块的Makefile里用PKG_CONFIG显式加一行依赖MOD_CFLAGS $(shell pkg-config --cflags speexdsp) MOD_LDFLAGS $(shell pkg-config --libs speexdsp)4.2 加载模块与配置文件模块编译安装完后要确认 FreeSWITCH 启动时能加载它。默认的模块自动加载列表在conf/autoload_configs/modules.conf.xml里需要加一行否则每次手动load module mod_asr太麻烦一重启就丢。!-- conf/autoload_configs/modules.conf.xml -- load modulemod_asr/ASR 模块自身的配置放在同级目录autoload_configs/asr.conf.xml模块启动时会自动读它。一个典型配置长这样configuration nameasr.conf descriptionASR Engine settings param nameengine-type valuehttp/ param nameengine-url valuehttp://127.0.0.1:8200/recognize/ param namelanguage valuezh-cn/ param nameinput-samplerate value16000/ param namemax-duration-ms value30000/ /settings /configuration这些参数的作用engine-type决定走本地库还是 HTTP 服务这个项目默认走 HTTP因为部署灵活引擎升级不用重编模块input-samplerate是引擎期望的采样率媒体回调里重采样就以此为基准max-duration-ms是单次识别的最长时长超过这个值还不出结果模块会强制终止并返回超时错误避免用户一直不挂电话把会话占死。配置文件的读取逻辑用的是 FreeSWITCH 自带的switch_xml_open_cfg解析不需要引入第三方 XML 库。4.3 用拨号计划跑通一轮识别编写一个拨号计划来验证模块是否工作。下面这段 XML 在用户拨通后先播报提示音然后启动 ASR等一句完整语音拿到结果后挂断并把结果打日志。extension nameasr-demo condition fielddestination_number expression^8000$ action applicationanswer/ action applicationplayback datasilence_stream://1000/ action applicationasr_start datastart/ action applicationlog dataINFO ASR result: ${asr_result}/ action applicationhangup/ /condition /extensionasr_start后面的datastart是这个应用的一个动作参数实际工程里还会支持stop、flush等。${asr_result}是一个通道变量模块在识别结果出来后会把文本塞进它注意它和事件系统是两条路——事件的读者是外部程序通道变量的读者是同一个 dialplan 里的后续动作。跑通后你会在控制台看到INFO ASR result: 我要订一张明天去上海的机票这类的日志说明整条链路已经闭合了。如果识别没反应先别急着怀疑引擎。先用一行命令确认媒体 bug 有没有创建成功、回调有没有被触发fs_cli -x show media_bugs asr有输出说明 bug 挂上了回调在跑没输出就回到第 3 章代码里查asr_exec里是不是没等 bug 挂成功就往返回了。这一步能帮你快速把问题锁定在媒体链路还是引擎返回省掉大量排错时间。5. 避坑指南FreeSWITCH ASR 接入的五个常见问题5.1 问题一模块加载正常却找不到 asr_start 应用现象load module mod_asr显示成功拨号计划里执行asr_start却报unknown application通话直接 hangup。原因模块文件装到了模块目录但 FreeSWITCH 的模块缓存没有刷新更常见的是注册应用接口的宏SWITCH_ADD_APPLICATION写在模块加载函数里但那个函数因为编译时宏定义不对根本没被导出。我在调试时遇到过一种隐蔽情况把 C 源文件直接塞进模块编译链但模块入口函数写在了.cpp文件里又没加extern C导致符号被 C mangling 改写FreeSWITCH 找不到入口。解决先fs_cli里执行module_exists mod_asr确认在注册表里再list applications里 grep 一下asr。都有问题的话把模块入口函数和SWITCH_MODULE_DEFINITION挪到.c文件里.cpp只放业务逻辑桥接层用extern C包住。从那以后我每个新模块都强制做一次这个检查治好了我经常犯的“忘了导出入口”的毛病。5.2 问题二识别结果全是乱码或空字符现象ASR 引擎有返回值但 FreeSWITCH 日志里看到的不是正常中文文本而是一堆问号或者空字符。原因编码问题FreeSWITCH 事件和日志默认走 UTF-8如果引擎返回的是 GBK 或 GB2312 编码的文本直接塞进事件就乱了。还有另一种情况引擎返回的字符串里包含非法 UTF-8 序列事件系统在序列化时把它丢弃了。解决在模块桥接层统一做编码清洗无论如何都以 UTF-8 为准// 第三方引擎结果清洗示例 std::string normalized NormalizeToUtf8(raw_text, engine_-GetSrcEncoding()); event_text normalized;NormalizeToUtf8内部先判断是不是合法 UTF-8不是再用iconv从 GBK 转到 UTF-8。这一步是典型的不做不知道、做了才觉得必要的环节。我之前被一个内部云引擎用 UTF-8、另一个用 GBK结果同一套代码换引擎就乱码最后就统一在桥接层收拢外部消费者永远只看到 UTF-8。5.3 问题三回调里直接调 session API周期性崩溃现象模块跑个几分钟FreeSWITCH 偶尔会 segfault崩溃点每次都在 ASR 模块里但看不出规律有时候是 high concurrency 才崩。原因媒体 bug 的 frame 回调运行在媒体线程而识别引擎结果回传时还可能触发定时器线程。两个线程同时操作同一个switch_core_session_tFreeSWITCH 的 session API 不是线程安全的。我最初的代码就在回调里直接switch_channel_set_variable写结果看起来没问题但一旦媒体线程和结果线程撞上同一时刻session 对象的引用计数直接被踩坏。解决进回调后不能直接调 session API要先把 session 指针取到用户数据结构里加锁或者干脆把“写通道变量”这个动作放回asr_exec所在的主线程。我是加了一把std::recursive_mutex凡是要动 session 的地方统一从锁里走识别结果先进队列再由 dialplan 轮询或事件系统消费不让业务线程直接触摸 session。5.4 问题四识别率断崖式下跌噪杂环境完全罢工现象安静环境测试识别率 90%一进嘈杂环境或者接通 VoIP 电话识别率掉到 20% 以下甚至整段音频被当成静音。原因FreeSWITCH 里默认的音频格式是 L16 线性 PCM但通话实际传的可能是 G.711 或者 Opus。G.711 还好Opus 到 PCM 需要解码如果模块直接在 frame 回调里拿到的就是已经解码的 PCM问题通常在采样率。VoIP 链路里常见 8k 采样率而识别引擎卖的就是 16k 的识别率8k 直接喂进去高频信息全丢了。解决模块里强制做重采样不管 FreeSWITCH 通道协商成什么格式统一重采样到引擎需要的input-samplerate。还有一个小技巧是开启降噪开关很多识别SDK支持denoisetrue在回调里把这一项透传出去。实测通过这两步能把嘈杂环境识别率拉回来一半以上。5.5 问题五媒体 bug 不清理内存泄漏现象模块长时间运行进程内存持续增长每个话务结束后 RSS 都没有回落。原因呼叫结束或模块 session 结束时代码里忘了调用switch_media_bug_remove。媒体 bug 是个独立对象挂在 session 上如果只在 session 销毁时才释放内存和句柄就只增不减。另一个常见原因是 bug 绑定的 user_data 是一个new出来的 C 对象bug 删除了但对象指针没有被释放。解决在asr_exec的返回路径上统一释放还要在 hangup 回调里兜底防止异常退出路径漏了。写一个钩子函数在 channel 销毁前检查 user_data 是否为nullptrstatic switch_status_t asr_hangup_callback(switch_core_session_t *session) { switch_media_bug_t *bug get_bug_from_session(session); if (bug ! nullptr) { switch_media_bug_remove(bug); } return SWITCH_STATUS_SUCCESS; }同时把switch_media_bug_add返回的错误码检查一下别在它失败时继续往下跑。很多内存泄漏都源于对资源释放路径不够隔离正确做法是入口和出口都做防护宁可重复释放也不能漏掉。6. 二次开发改造思路从固定命令到参数可配的 ASR 服务6.1 把引擎地址改为可配置的这个模块最初是硬编码引擎 URL 的改造成支持配置文件之后版本迭代省了很多事。做法是在配置解析时加一个param nameengine-timeout value3000/和param namefallback-language valueen-us/然后把这些配置项传给AsrEngine的构造函数。值班改语言、改超时不用重新编译模块改配置重启就生效。6.2 识别引擎抽象成可替换的接口更值得推荐的是一个接口化改造把AsrEngine定义成纯虚类派生出HttpAsrEngine和LocalAsrEngine两个实现。HTTP 版用一个 HTTP 客户端库把 PCM 流 POST 上去本地版直接链接一个 C 语言识别库。模块内部只依赖抽象接口不关心具体实现。这样未来如果需要加第 3 个引擎只需要新建一个类在工厂函数里加一行分支即使默认还是走 HTTP 引擎也不会影响现有链路。class IAsrEngine { public: virtual void Start(const AsrRequest req) 0; virtual void FeedAudio(const char *data, size_t len) 0; virtual AsrResult Stop() 0; };6.3 改造后的验证方法改造完别急着上线先做一轮覆盖用三种不同采样率的音频文件拨进来8k、16k、48k 重采样后的效果再同时发起两路呼叫测并发下的内存是否稳定最后用事件订阅脚本检查asr::result事件里能不能拿到Channel-Unique-ID确保外部业务能准确对应到每一路通话。每加一个引擎这套测试我都强制走一遍原来只测 16k 音频代码看着没问题但一接真实电话就翻车——从那以后我养成了习惯任何识别模块改动后都跑一遍三类音频和并发验证它已经成了我交付前最后一道关卡。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站