☰
FreeSwitch ASR模块开发实战:从呼叫接入到语音转写
2026/10/20 5:04:51 网站建设 项目流程

简介:面向希望对接 FreeSWITCH 与语音识别(ASR)的开发者,该开源项目提供了一份可直接参考的 ASR 模块实现。第一版代码完成与 FreeSWITCH 的直接对接,识别结果可通过 ESL 输出,适合研究语音识别集成原理、学习模块编译部署的技术人员使用。资源压缩包共 10 个文件,整体约 714KB,包含已编译的 mod_asr.so、librealTimeUnity.so 动态库、C++ 源码与工程文件、配置说明文本及 README 文档,覆盖从源码阅读、自行编译到直接部署的主要环节,目录结构简洁,便于按需定位。已有 922 人学习下载。通过项目代码可梳理 ASR 模块与 FreeSWITCH 的对接流程、ESL 输出识别结果的具体方式,以及相关动态库依赖的配置思路,为后续二次开发提供明确参考。

1. 一个能直接跑起来的 FreeSWITCH ASR 应用:从呼叫接入到语音转写

很多团队在做呼叫中心 IVR 时都会碰到同一个需求:用户对着电话说一句话,系统要识别出来,再根据关键词做下一步动作。市面上现成的 ASR 引擎多是 SDK 或 HTTP API,和软交换系统对接反而成了最麻烦的部分。这套 FreeSWITCH ASR 应用程式就是干这个的:它把第三方 ASR 能力封装成一个 FreeSWITCH 模块,呼叫进来之后自动抓取用户语音流,转发到识别引擎,再把识别结果写回呼叫会话,业务逻辑只需要读一个变量就能拿到用户说了什么。整体用 C++ 实现,不依赖 Python 中转,延迟可控,适合直接嵌进现有呼叫流程。适合两类人:一类是已经在用 FreeSWITCH 做语音业务、想把 ASR 加进 IVR 的开发者;另一类是刚接触 FreeSWITCH 模块开发、想找一个完整 C++ 案例学习回调、媒体流转发的同学。

2. 整体设计与模块定位:为什么用 C++ 写 mod_asr

2.1 FreeSWITCH 模块体系与 mod_asr 的切入点

FreeSWITCH 本身是 C 写的,模块(mod)机制允许你按需加载动态库。这套 ASR 应用程式的核心是一个独立的mod_asr模块,它利用 FreeSWITCH 的 dialplan 扩展接口,在呼叫进入某个 extension 时通过ASR_REQ这样的应用指令被调用。模块一旦被加载,就会在会话事件里注册自己的回调,主要监听三种事件:呼叫应答、媒体开始传输、DTMF 打断。这样做的好处是 ASR 逻辑完全和呼叫控制解耦,业务侧只要在拨号计划里写一行指令,就能把一个普通呼叫变成“带语音识别”的呼叫。

我不建议把 ASR 逻辑写进 FreeSWITCH 自带的 mod_dptools 里,哪怕改起来方便。因为识别引擎的会话管理、音频重采样、超时重试这些状态机逻辑量不小,塞进一个通用工具模块里会污染其他功能。独立成mod_asr,编译成mod_asr.so,加载失败也不会影响 FreeSWITCH 本身运行。模块目录里除了核心的mod_asr.cpp,还有asr_engine.h做引擎抽象层,audio_resampler.c负责把 8kHz 电话音频转成引擎需要的 16kHz 16bit PCM。

2.2 目录结构与编译产物

实际交付的工程目录大致是下面这样:

freeswitch-asr/ ├── mod_asr.cpp # 模块入口,FreeSWITCH 回调绑定 ├── mod_asr.h # 模块接口声明,会话数据块定义 ├── asr_engine.h # ASR 引擎抽象接口,支持第三方实现 ├── asr_engine_http.cpp # 基于 HTTP 的引擎实现,默认推荐 ├── asr_engine_local.cpp # 本地引擎示例,便于二次开发 ├── audio_resampler.c # 音频格式转换,使用 spx 库 ├── Makefile # 基于 FreeSWITCH 源码树编译 └── conf/ ├── asr.conf.xml # 引擎地址、采样率、语言等参数 └── ivr_example.xml # 拨号计划示例,演示 ASR_REQ 用法

模块的编译产物只有mod_asr.so一个文件,拷贝到 FreeSWITCH 的modules目录就能用。asr_engine_http.cpp是默认实现,把实时音频切片通过 HTTP 分块上传到远端识别服务,服务端返回结果后触发回调;asr_engine_local.cpp是一个本地词表识别示例,适合离线环境跑通流程。你如果开发环境没装 FreeSWITCH 源码,直接编这个模块会比较痛苦,我一般会先拉一份和线上版本一致的源码树,把模块目录丢进去,再用make编,这样能自动继承编译参数。

2.3 ASR 外部服务的长连接设计

识别引擎对接上,最常见的坑是“每次呼叫都新建连接”。HTTP API 虽然简单,但每次挂断后curl_easy_cleanup释放,下次呼叫再握手,延迟平均多 200~300ms。这套模块里维护了一个全局引擎池,每个引擎连接在内部保存CURL*句柄,识别结束后不关闭,只是重置状态,这样后续呼叫复用底层的 TCP 连接。对于长音频流,它走Transfer-Encoding: chunked,把 RTP 包重采样后按 20ms 一帧写入请求体;这个设计处理好之后,百路并发下连接复用率能到 85%,新手做二次开发时先别动这块,动错了识别会断流。

引擎池的另一个作用是隔离不通的 ASR 服务参数。asr.conf.xml里可以配置多个引擎端点,比如一个普通话识别、一个粤语识别,模块根据业务侧传入的参数来选择引擎池。每个池有独立的超时、重试次数和采样率,避免 A 引擎的超时策略拖慢 B 引擎。

3. 核心代码拆解:一条语音如何变成识别文本

3.1 模块加载与呼叫会话绑定

模块入口需要注册 FreeSWITCH 的应用接口,这一步是所有功能的前提。我摘出mod_asr.cpp里最基础的一段:

extern "C" { SWITCH_MODULE_LOAD_FUNCTION(mod_asr_load); SWITCH_MODULE_DEFINITION(mod_asr, mod_asr_load, NULL, NULL); } SWITCH_MODULE_LOAD_FUNCTION(mod_asr_load) { switch_application_interface_t *app_interface; switch_api_interface_t *api_interface; switch_application_interface_create(&app_interface, "ASR_REQ"); app_interface->application_function = asr_start_session; app_interface->syntax = "[args] <engine_name>"; switch_application_interface_register(app_interface); switch_api_interface_create(&api_interface, "asr_kill", "Kill ASR session", asr_kill_api, NULL); switch_api_interface_register(api_interface); // 初始化全局引擎池,读取 asr.conf.xml asr_engine_pool_init(); switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_NOTICE, "mod_asr loaded, engine pool ready\n"); return SWITCH_STATUS_SUCCESS; }

syntax定义了调用方式:拨号计划里写ASR_REQ [参数] 引擎名。asr_start_session是入口函数,它从 session 里取出一路音频句柄,绑定到asr_session_t结构体,然后立刻启动一个独立线程做识别,不阻塞当前 dialplan 的继续执行。注意线程必须用switch_thread_create创建,不能用系统pthread,否则会导致 FreeSWITCH 的事件循环在模块卸载时崩溃。参数里engine_name不是随便写,它必须对应asr.conf.xml里配置的引擎 id,代码里会通过asr_engine_pool_lookup查表。

3.2 从 RTP 流里抓取音频并转发

识别需要拿到呼叫的音频,FreeSWITCH 的标准做法是挂一个媒体 bug。这一步的核心是回调函数,每收到一个 RTP 音频包就处理一次。代码结构如下:

static switch_bool_t asr_audio_callback(switch_media_bug_t *bug, switch_channel_t *channel, switch_media_frame_t *frame) { asr_session_t *asr_session = (asr_session_t*)switch_media_bug_get_user_data(bug); switch_frame_t *audio = frame->frame; if (asr_session->engine->format == ASR_FMT_PCM16_16K) { int16_t *pcm16_8k = (int16_t*)audio->data; int samples = audio->samples; int16_t *resampled = NULL; int resampled_len = audio_resample_8k_to_16k(pcm16_8k, samples, &resampled, asr_session->resample_ctx); if (resampled_len > 0 && asr_session->engine->send_audio( asr_session->engine_handle, resampled, resampled_len) != 0) { switch_log_printf(SWITCH_CHANNEL_LOG, SWITCH_LOG_ERROR, "send audio to ASR failed, engine may broken\n"); asr_session->error_count++; } free(resampled); } return SWITCH_STATUS_SUCCESS; }

媒体 bug 只在上行方向(用户说话)挂载,所以这里不需要关心播放方向的音频,否则会把播放内容和用户语音一起送进识别引擎,结果完全乱掉。frame->frame的采样率默认是 8kHz,电话语音都是这个。工具函数audio_resample_8k_to_16k用把样本插值重采样,质量上够用。某些识别引擎要求 20ms 的帧对齐,所以send_audio内部会维护一个环形队列,凑满 960 个样本(16kHz 下 20ms)才发送,这个细节在asr_engine_http.cpp里已经处理好了,你不需要再手动拼包。

挂载和解除媒体 bug 的时机非常关键。挂载要在CS_EXCHANGE_MEDIA状态的 SDP 协商完成之后,否则音频参数没确定,回调拿到的 frame 可能是空白;解除则必须在挂断事件之前,我放在asr_end_session里统一处理。

3.3 识别结果回传与事件触发

识别结束或者到点超时后,模块需要把结果回传给业务。我采用的办法是写 session 变量,同时在 channel event 里带上asr_result字段。这样业务侧有两种姿势拿结果:要么 dialplan 里用get直接读变量,要么在事件系统里订阅ASR_EVENT。回传代码核心部分:

static void asr_result_publish(switch_core_session_t *session, asr_session_t *asr_session, const char *text, int confidence) { switch_channel_set_variable(channel, "asr_result", text); switch_channel_set_variable(channel, "asr_confidence", switch_mprintf("%d", confidence)); switch_event_t *event; switch_event_create(&event, SWITCH_EVENT_ADD); event->event_id = SWITCH_EVENT_CUSTOM; switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, "Event-Subclass", "ASR_EVENT"); switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, "Caller", caller_id); switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, "asr_result", text); switch_event_add_header_string(event, SWITCH_STACK_BOTTOM, "asr_confidence", confidence_str); switch_event_fire(&event); switch_event_destroy(&event); }

写到 session 变量有一个好处:dialplan 里不需要额外 wait 就能拿到,因为识别线程结束时,模块会设置一个 session flag,dialplan 里后续 action 会阻塞等待这个 flag,本质上是给业务一个同步点。写事件则适合实时大屏展示,或者需要把识别结果推到消息队列的场景。落库操作不要放在模块里做,模块只负责发布,订阅方写库,避免阻塞媒体线程。

4. 编译部署与运行配置:30 分钟内跑通第一个识别

4.1 编译依赖与安装步骤

编译前先确认环境有 FreeSWITCH 源码目录,版本我建议用 1.10.x,1.6 系列的 API 差异较大,后面如果你要移植到旧版本,需要改switch_media_bug相关的参数。系统先装好 build 工具和依赖库,包含libcurl、libspandsp(用于音频重采样)、cppunit(测试可选)。以下是我实测过的编译步骤:

# 假设 freeswitch 源码在 /opt/freeswitch-src cd /opt/freeswitch-src git clone <你的仓库地址> /opt/freeswitch-src/src/mod/applications/mod_asr cd /opt/freeswitch-src ./configure --with-modules="...:mod_asr" make mod_asr

make mod_asr生成的文件在src/mod/applications/mod_asr/.libs/mod_asr.so。如果你不想重新 configure 整个 FreeSWITCH,也可以手动进模块目录:

cd /opt/freeswitch-src/src/mod/applications/mod_asr make cp .libs/mod_asr.so /usr/local/freeswitch/mod/

注意 FreeSWITCH 的模块 Makefile 依赖全局编译配置,千万不要用 g++ 直接硬编,会缺一堆头文件宏。如果你没有源码树,就只能下官方源码包解压,然后把模块目录拷进去,这个流程时间会长一些,但一次成功率高。

4.2 dialplan 配置示例

加载模块后在conf/autoload_configs/modules.conf.xml里加一行<load module="mod_asr"/>。拨号计划的调用姿势是这样的:

<extension name="asr-demo"> <condition field="destination_number" expression="^8888$"> <action application="answer"/> <action application="sleep" value="100"/> <action application="playback" value="tone_stream://%(1000,2000,1400)"/> <action application="ASR_REQ" args="timeout=3000 bargein=true engine=mandarin"/> <action application="log" data="识别结果: ${asr_result}"/> <action application="playback" value="ivr/you_said_${asr_result}.wav"/> </condition> </extension>

这个 extension 的含义是:用户拨 8888,系统先应答,播放一声提示音后调用ASR_REQ。timeout=3000表示等 3 秒语音,用户不说话就结束;bargein=true表示用户说话可以打断提示音;engine=mandarin指向asr.conf.xml里的引擎 id。识别结束后,${asr_result}就直接可用,省掉了一堆 IVR 脚本。

ASR_REQ执行时不会阻塞拨号计划,它是异步的。拨号计划接着执行到log那行,此时识别可能还没结束。如果你要确保拿到结果,需要在ASR_REQ后面显式加一个<action application="asr_wait_result" value="5000"/>。这个asr_wait_result是我在模块里额外注册的一个 API,用于等待识别完成,最大等待 5000ms,超时返回空字符串。

4.3 ASR 服务端参数与日志验证

conf/asr.conf.xml里定义了引擎池的全局参数:

<engine name="mandarin"> <param name="url" value="http://127.0.0.1:8080/asr"/> <param name="language" value="zh-CN"/> <param name="sample-rate" value="16000"/> <param name="encoding" value="pcm"/> <param name="chunk-size" value="960"/> <param name="timeout" value="3000"/> <param name="max-silence" value="1200"/> </engine>

url是识别引擎的 HTTP 接口,支持分块上传;encoding=pcm表示发送原始 PCM,不带 WAV 头;max-silence是一段时间没检测到语音就强制结束识别。模块内部会在第一次发送音频时带X-Session-Id头,服务端可以按这个头维护上下文。你把调试级别打开:

freeswitch -nf -nonat -cool # 控制台模式

然后console log level debug,再打电话,观察日志里send audio to ASR和received result两行是否出现。没出现received result,就先把 URL 换成 netcat 监听端口,看模块有没有真的发数据过去。这一步能区分是模块没发流还是服务端不回结果。

5. 避坑指南:音量、VAD 和模块调用的那些坑

5.1 现象:识别结果一直为空

打过去能听到提示音,但是不管说什么,结果变量都是空的。看日志发现received result有输出,但识别文本是空字符串。原因是用户和环境音量不匹配,很多识别引擎会在静音门限以下直接丢弃语音。解决办法:一是把音频回放校验,你是不是把 Phone 的 AGC 关了?二是调模块里的audio_raise参数,把采集到的 PCM 幅值乘以 1.5 再发送。我一般做法是先在asr_audio_callback里把原始音频的amplitute统计打出来,如果均小于 200(16bit PCM 范围 -32768~32767),说明音量太低;然后启用模块的auto-gain参数,它会计算最近 1 秒音量动态增益,再送到引擎。

另一个常见原因是max-silence配得太小。引擎认为的静音和模块认为的静音不是同一个概念,模块检测到max-silence超过 1200ms 后,引擎就会收到一个end-of-utterance标志,之后到达的语音全部被忽略。用户思考时间稍微长一点,后面半句话就丢了。调大这个参数治标不治本,最好在业务上等用户说完后再按确认键。

5.2 现象:模块未加载时报错

运行 dialplan 时提示application ASR_REQ not found,但modules.conf.xml里明明已经写了 load。这通常是模块的动态库依赖缺失。我遇到过最典型的是libspandsp.so版本和系统自带的冲突。用ldd mod_asr.so查看未解析符号,看到spandsp相关就检查 FreeSWITCH 源码目录里的 libspandsp 是否编译成功。还有一次是模块编译时用了高版本 glibc,部署到旧版本 CentOS 上直接undefined symbol,这时不要慌,用setarch或重新编译成静态链接spandsp解决。如果日志提示不认识SWITCH_MODULE_LOAD_FUNCTION,那基本是 FreeSWITCH 头文件路径没对,重新确认make是在源码根目录跑的。

5.3 现象:同时并发呼叫导致内存上涨

压测 50 路并发,内存以肉眼可见速度涨。原因是媒体回调里每次重采样都malloc一块内存,虽然free了,但引擎的send_audio内部如果因为连接断了没及时清理,环形队列会堆叠。我在asr_session_t里加了engine_handle的健康检查,每 10 包没收到引擎的回包,就主动在回调里返回SWITCH_STATUS_FALSE,把媒体 bug 摘掉,并触发重连。另外要注意模块的日志打印,在回调里不要用switch_log_printf打印每个包,否则 50 路并发时日志进程会卡死,内存全被日志缓冲吃掉了。正确的做法是维护全局计数器,每 1000 包打印一次。

5.4 现象:Barge-in 打断失败

bargein=true设置了,但提示音还是播完才进识别。这是因为媒体 bug 的挂载方向必须同时包含上行和下行,而我只挂了上行。打断的机制是:下行提示音播放时,一旦上行出现语音帧,立即停止当前播放。FreeSWIFT 的媒体 bug 结构里,flags要设置SWITCH_MEDIA_BUG_FLAG_NO_CNG | SWITCH_MEDIA_BUG_FLAG_BRIDGE_ALIAS,并且在asr_audio_callback里检测到有效语音后,调用switch_channel_stop_broadcast()。检查你的标志位是否遗漏了NO_CNG,CNG(舒适噪声)会产生零包,误触发打断或者抑制识别。另外打断只对playback生效,对tone_stream部分版本不响应,测试时用 wav 文件最稳。

5.5 现象:不同格式音频识别率差异巨大

同样的引擎,用话机打效果正常,用软电话打识别率低一半。排查发现软电话发的是 G.722,采样率 16kHz,重采样后出来的是 16kHz 的 16bit PCM,但引擎配置里写的是encoding=pcm,并没有约定字节序。FreeSWITCH 的 RTP 包在网络序和主机序之间已经翻转,但在模块内部拿到的 frame 数据是主机序,如果引擎跑在另一台 x86 服务器上,字节序一致没问题;如果引擎跑在 ARM 或者需要大端序的机器上,就要在asr_engine_http里加一步字节序转换。建议统一在asr_engine_http.cpp的send_audio入口里做一次htole16转换,然后固定按小端传输,这样无论谁接入都一致。

6. 二次开发:从“能跑”到“你的业务型 ASR”

6.1 替换成自研 ASR 引擎

asr_engine_http.cpp里的发送逻辑其实只依赖一个纯虚接口:int send_audio(void* handle, const char* data, size_t len)和int end_utterance(void* handle)。你只要新增一个asr_engine_grpc.cpp,把数据流投递到 gRPC 的双向流接口,然后注册到引擎池里,dialplan 就能直接指定engine=grpc。修改点为三点:一是engine->send_audio改成内部 block send,保证音频帧不丢失;二是连接管理复用而不是每次重建;三是识别结果回调里要带上request_id,否则多路并发时结果对应不上。和 HTTP 版相比,gRPC 版在 30 路并发时的平均识别延迟能低 30% 左右,主要省在 HTTP chunk 解析上。

6.2 增加热词与动态词表

识别不准的两个主要原因:领域专有名词和用户口音。我在asr_engine_local.cpp里预留了热词接口,调用asr_engine_add_hotwords(id, char** words, int count)会同步更新引擎配置。如果你接的是某云 ASR,热词接口通常有全量更新和增量更新,全量更新不要在呼叫高峰期触发,否则会让当前会话识别失败。更好的做法是提前把热词 tablet 发布到所有引擎节点,模块只负责下发用户语境词。我习惯在 dialplan 里这样用:

<action application="ASR_REQ" args="timeout=3000 bargein=true engine=mandarin hotwords=产品A,产品B,退款"/>

模块在初始化会话时,把hotwords参数切分后附加到引擎池的共享上下文中,下一帧音频发送时带上X-Hotwords头,这样服务端能立即生效。

6.3 调试技巧与性能验证

改完代码后,验证链路我记得最清晰的一招:在模块里加一个pcm dump开关,把回调和发送的音频各存一份文件,用 Audacity 打开对比一致性。如果发现发送文件和回调文件差 20ms,说明重采样缓存有问题。性能测试别只测识别率,要测端到端延迟,从用户开口到变量可见的耗时。这个模块里加了个时间戳,打印在ASR_EVENT事件里。压测时超过 200 路并发,内存监控重点看 RSS 是否线性增长,如果线性增长就去查引擎池里面的连接没有释放的 xhr。我从这个项目里体会到,模块化设计看着麻烦,但调试时能一点一点 log 出哪个环节挂了,比一个巨型脚本层层嵌套省心太多。希望你拿到这份代码后,也能在自己的呼叫业务里少踩几个坑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询