1. 项目概述:为什么我们需要一个自动化的通话录音方案?
在不少业务场景里,通话录音是一个刚需。比如,客服团队需要记录与客户的沟通细节,用于后续的质检和培训;自由职业者或小团队需要留存与客户的沟通凭证,避免后续的纠纷;甚至对于个人,有时也需要记录一些重要的电话会议或关键信息。然而,手动操作不仅繁琐,还容易遗漏。想象一下,你正在开车或者手头正忙,一个重要电话打进来,你既要接听,还要分心去找录音按钮,体验非常糟糕。
“自动接听 + 自动录音”这个组合,就是为了解决这个痛点而生。它意味着系统能够像一位不知疲倦的助理,在电话呼入时自动帮你接起来,并同时开启录音,全程无需你手动干预。等你有空了,再回头去听录音或者查看文字转写稿。这个需求听起来简单,但真要自己从头实现,涉及到电话线路、信令控制、媒体流处理、文件存储等一系列环节,门槛不低。
市面上当然有成熟的商业解决方案,但要么价格不菲,要么在数据隐私、功能定制上无法满足特定需求。因此,一个开源的、可自我掌控的实现方案,对于开发者、技术团队或是有定制化需求的企业来说,价值就凸显出来了。今天要聊的,就是如何利用开源技术栈,搭建一套属于你自己的自动化通话录音系统。我们将聚焦于一个经典且可行的技术组合,并深入每个环节的“为什么”和“怎么做”。
2. 核心架构与开源技术选型解析
要实现自动接听和录音,我们需要一个核心组件来处理电话信令(SIP协议)和媒体流(音频)。在开源世界里,FreeSWITCH是这方面当之无愧的王者。它是一个强大的软电话交换平台,功能极其丰富,我们只需要用到其冰山一角——作为一个SIP服务器(B2BUA)来接收来电,并执行我们设定的呼叫流程。
2.1 为什么是FreeSWITCH?
首先,它稳定且成熟,在全球范围内被广泛用于生产环境。其次,它的控制方式非常灵活,既可以通过内置的XML配置定义简单的IVR(交互式语音应答)菜单,也可以通过ESL(Event Socket Library)接口用外部程序(比如Python、Node.js)进行实时、精细化的控制。对于我们的“自动接听+录音”场景,初期用XML配置就能快速跑通,后期若需要复杂逻辑(比如根据来电号码决定是否接听),可以无缝升级到ESL控制。
除了FreeSWITCH,另一个常见选择是Asterisk。两者都是优秀的开源PBX。FreeSWITCH在某些方面,如高性能和并发处理上更有优势,且其模块化设计和API对开发者更友好。对于我们这个以“自动化控制”和“集成”为重点的项目,FreeSWITCH通常是更顺手的选择。
2.2 媒体流处理与录音生成
FreeSWITCH接听电话后,会建立音频媒体流。录音功能本质就是将这段双向的音频流(你说话的声音和对方说话的声音)混合后,保存为音频文件。FreeSWITCH内置了强大的mod_dptools: record_session或mod_dptools: record应用,可以轻松实现单声道或立体声录音。
但光有录音文件还不够。我们可能还需要:
- 录音文件管理:自动按日期、主叫号码等规则命名和存储。
- 语音转文字(ASR):将录音内容转为文本,便于搜索和快速浏览。
- 事件监听与业务集成:录音开始、结束、生成文件时,需要通知我们的业务系统。
这就需要第二个核心组件:一个自定义的应用服务。我们可以用Python(Flask/Django)、Node.js、Go等任何你熟悉的语言来编写。这个服务通过ESL监听FreeSWITCH的事件,当有来电被接听时,触发录音指令;录音结束后,获取文件路径,并调用第三方ASR接口(如阿里云、腾讯云、或开源的Vosk)进行转写,最后将元数据(来电号码、时间、录音文件URL、转写文本)存入数据库或推送到消息队列。
2.3 整体数据流梳理
整个方案的数据流可以清晰地分为几个阶段:
- 呼入阶段:外部电话(PSTN或手机网络)通过运营商或SIP中继,将呼叫请求(INVITE)发送到你的FreeSWITCH服务器。
- 信令处理与自动接听阶段:FreeSWITCH根据预设的拨号计划(Dialplan),匹配到来电,并执行“answer”(应答)动作。这就是“自动接听”。
- 媒体建立与录音启动阶段:通话接通后,FreeSWITCH在拨号计划中继续执行“record”应用,开始将通话双方的音频写入指定路径的文件。
- 事件通知与后处理阶段:录音结束时,FreeSWITCH通过ESL向我们的应用服务发送包含文件路径的
CHANNEL_EXECUTE_COMPLETE事件。应用服务捕获该事件,启动文件转码、ASR转写、元数据入库等异步任务。 - 存储与访问阶段:最终的录音文件(如WAV格式)和转写文本被存储起来(本地磁盘、对象存储如MinIO/S3),并通过一个简单的Web界面或API提供查询和播放功能。
这个架构清晰地将通信底层(FreeSWITCH)和业务逻辑(应用服务)解耦,使得两者可以独立扩展和维护。
3. FreeSWITCH核心配置详解
理论说完,我们进入实战。假设你已经在服务器上安装好了FreeSWITCH(安装过程略,官方文档很详细)。接下来,我们需要配置最关键的部分:拨号计划(Dialplan)。
3.1 拨号计划配置:实现自动接听与录音
FreeSWITCH的拨号计划决定了来电该如何被处理。我们需要编辑conf/dialplan/default.xml文件(或创建一个新的XML文件放在conf/dialplan/public/目录下)。
下面是一个最精简但功能完整的配置示例:
<context name="public"> <extension name="auto_answer_and_record"> <!-- 匹配所有来电 --> <condition field="destination_number" expression="^(\d+)$"> <!-- 1. 自动应答 --> <action application="answer"/> <!-- 等待100毫秒,确保媒体通道完全建立 --> <action application="sleep" data="100"/> <!-- 2. 播放提示音(可选),告知对方通话将被录音 --> <action application="playback" data="ivr/ivr-call_will_be_recorded.wav"/> <!-- 3. 开始录音 --> <!-- 关键参数解析: ${record_file}: 变量,用于存储录音文件路径。 /var/lib/freeswitch/recordings/: 录音文件存储根目录。 ${strftime(%Y-%m-%d-%H-%M-%S)}: 按年月日时分秒生成时间戳。 ${caller_id_number}: 主叫号码。 ${destination_number}: 被叫号码(即你的FS号码)。 .wav: 文件格式。也可以使用.mp3,但需要编译时包含mod_shout。 ${record_sample_rate=8000}: 设置采样率为8kHz,节省空间。 ${record_stereo=false}: 单声道录音,足够清晰且文件更小。 --> <action application="set" data="record_file=/var/lib/freeswitch/recordings/${strftime(%Y-%m-%d-%H-%M-%S)}_${caller_id_number}_to_${destination_number}.wav"/> <action application="set" data="record_sample_rate=8000"/> <action application="set" data="record_stereo=false"/> <action application="record_session" data="${record_file}"/> <!-- 4. 挂断后,执行一个自定义的脚本(用于通知外部服务) --> <!-- 这里使用`httapi`或通过ESL事件处理是更优解,本例为演示简单逻辑 --> <action application="log" data="INFO Recording saved: ${record_file}"/> </condition> </extension> </context>配置要点与避坑指南:
- 存储路径权限:确保FreeSWITCH运行用户(通常是
freeswitch或www-data)对/var/lib/freeswitch/recordings/目录有读写权限。 - 文件名规范:使用时间戳和号码组合命名,能有效避免重名,并便于后续检索。注意文件名中避免使用特殊字符。
- 录音格式:WAV格式保真度最高,但文件大。MP3格式文件小,但需要额外的编码库(如lame)和支持模块(如mod_shout)。生产环境建议先录为WAV,再由后端服务统一转码为MP3以节省存储。
- 提示音:播放录音提示音不仅是良好的用户体验,在某些地区也是法律要求。确保提示音文件(如.wav格式)已放置在FreeSWITCH的语音文件目录(如
/usr/share/freeswitch/sounds/)下。
3.2 通过ESL实现更精细的控制
上述XML配置实现了基础功能,但不够灵活。比如,我们想只对特定号码录音,或者录音完成后立即将文件信息POST到我们的Webhook。这时就需要用到ESL。
我们可以写一个Python脚本,连接到FreeSWITCH的ESL端口(默认8021),订阅相关事件,并控制呼叫。
# record_agent.py import ESL import sys import datetime # 连接到FreeSWITCH ESL con = ESL.ESLconnection('localhost', '8021', 'ClueCon') if not con.connected(): print("无法连接到FreeSWITCH ESL") sys.exit(1) # 订阅所有事件,我们只过滤需要的 con.events('plain', 'all') while True: e = con.recvEvent() if e: event_name = e.getHeader('Event-Name') # 监听电话应答事件 if event_name == 'CHANNEL_ANSWER': uuid = e.getHeader('Unique-ID') caller_id = e.getHeader('Caller-Caller-ID-Number') dest_number = e.getHeader('Caller-Destination-Number') print(f"[{datetime.datetime.now()}] 来电接听: {caller_id} -> {dest_number}, UUID: {uuid}") # 构建录音文件名 record_file = f'/var/lib/freeswitch/recordings/{datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")}_{caller_id}_to_{dest_number}.wav' # 通过ESL命令启动录音 # `uuid_record` API 是针对特定通道的录音 api_cmd = f'uuid_record {uuid} start {record_file}' result = con.api(api_cmd) print(f" 启动录音: {api_cmd}, 结果: {result.getBody()}") # 将录音文件路径与通话UUID关联存储(例如存入Redis),供后续事件使用 # redis_client.set(f'record:{uuid}', record_file) # 监听通话挂断事件 elif event_name == 'CHANNEL_HANGUP_COMPLETE': uuid = e.getHeader('Unique-ID') # 从Redis获取录音文件路径 # record_file = redis_client.get(f'record:{uuid}') # if record_file: # print(f"通话 {uuid} 结束,录音文件: {record_file}") # # 这里可以触发后续处理:调用转写API、入库等 # # process_recording(record_file, uuid) print(f"[{datetime.datetime.now()}] 通话挂断: UUID: {uuid}")这个脚本只是一个骨架。在生产环境中,你需要:
- 使用
python-esl库或socket直接连接ESL。 - 加入更健壮的错误处理和日志。
- 使用消息队列(如Redis List/RabbitMQ)将“录音完成”事件异步传递给后处理Worker,避免阻塞ESL连接。
- 考虑并发连接的管理。
注意:直接使用
mod_event_socket并编写ESL客户端,赋予了最大的灵活性,但同时也增加了复杂性。对于简单场景,拨号计划中的record_session配合httapi模块(可以通过HTTP回调通知你的服务)可能是更轻量的选择。
4. 录音后处理服务搭建实战
录音文件生成后,躺在FreeSWITCH的服务器上。我们需要一个常驻的后处理服务来完成“收尾”工作。
4.1 服务职责与设计
这个服务(我们称之为Recording-Processor)的核心职责如下:
- 监听事件:从消息队列(或直接监听ESL)获取“录音文件已就绪”事件。
- 文件转码:将高保真的WAV文件转换为更节省空间的MP3或OPUS格式。
- 语音转文字:调用ASR服务,生成录音文本。
- 元数据提取与存储:从文件名或事件信息中提取主叫号、被叫号、时间、时长等,连同文件存储路径、转写文本一起存入数据库。
- 文件管理:将最终的录音文件转移到更持久的对象存储中,并清理FreeSWITCH服务器上的原始文件。
4.2 技术栈示例(Python + FastAPI + Celery)
这是一个非常流行的异步任务处理组合。
- FastAPI:提供轻量级的Webhook端点,接收来自FreeSWITCH(通过
httapi)或我们自己ESL客户端推送的事件。 - Celery:分布式任务队列,用于处理耗时的转码和ASR任务。
- Redis:作为Celery的消息代理(Broker)和结果后端(Result Backend),同时也可以用作临时缓存。
- PostgreSQL/MySQL:存储录音元数据。
- MinIO/S3:对象存储,用于存放最终的录音文件。
核心代码结构示例:
# app/main.py (FastAPI 应用) from fastapi import FastAPI, BackgroundTasks from pydantic import BaseModel import httpx from celery_app import process_recording_task app = FastAPI() class RecordingEvent(BaseModel): file_path: str # FreeSWITCH服务器上的原始文件路径 caller_id: str callee_id: str start_time: str duration: int @app.post("/webhook/recording-complete") async def handle_recording_complete(event: RecordingEvent, background_tasks: BackgroundTasks): """接收录音完成事件的Webhook端点""" # 立即响应FreeSWITCH,避免超时 # 将耗时任务放入后台队列 background_tasks.add_task(process_recording_task.delay, event.dict()) return {"status": "accepted"} # celery_app.py (Celery 应用) from celery import Celery import subprocess import os from vosk import Model, KaldiRecognizer import json import wave # 配置Celery,使用Redis作为Broker celery_app = Celery('tasks', broker='redis://localhost:6379/0') @celery_app.task def process_recording_task(event_data): file_path = event_data['file_path'] # 1. 转码 WAV -> MP3 (使用ffmpeg) mp3_path = file_path.replace('.wav', '.mp3') cmd = ['ffmpeg', '-i', file_path, '-codec:a', 'libmp3lame', '-qscale:a', '2', mp3_path] subprocess.run(cmd, check=True, capture_output=True) # 2. 语音转文字 (使用开源Vosk模型,轻量级) model_path = "/path/to/vosk-model-small-en-us-0.15" if not os.path.exists(model_path): # 如果没装Vosk,可以跳过,或调用云API text = "[ASR not configured]" else: wf = wave.open(file_path, "rb") model = Model(model_path) rec = KaldiRecognizer(model, wf.getframerate()) text = "" while True: data = wf.readframes(4000) if len(data) == 0: break if rec.AcceptWaveform(data): result = json.loads(rec.Result()) text += result.get("text", "") + " " result = json.loads(rec.FinalResult()) text += result.get("text", "") wf.close() # 3. 上传到对象存储 (以MinIO为例) from minio import Minio client = Minio('minio.example.com', access_key='your-key', secret_key='your-secret', secure=False) bucket_name = 'call-recordings' object_name = os.path.basename(mp3_path) client.fput_object(bucket_name, object_name, mp3_path) file_url = f"http://minio.example.com/{bucket_name}/{object_name}" # 4. 元数据入库 (使用SQLAlchemy等ORM) # db.add(RecordingRecord(caller=..., file_url=..., transcript=text, ...)) # db.commit() # 5. 清理临时文件 os.remove(file_path) os.remove(mp3_path) print(f"处理完成: {object_name}, 转写长度: {len(text)}")4.3 关键环节的注意事项
- ffmpeg依赖:确保服务器上安装了
ffmpeg,并且FreeSWITCH有执行系统命令的权限。 - ASR选择:Vosk是优秀的离线开源ASR,支持多种语言,但准确率尤其是对中文电话录音的识别,可能不如商业云服务(如阿里云短语音识别)。如果对准确率要求高,建议使用云服务API,但需考虑成本和网络延迟。
- 文件权限与隔离:处理服务运行用户必须有权限读取FreeSWITCH生成的录音文件,并在处理后删除它们。考虑使用同一用户组或设置适当的ACL。
- 任务幂等性:网络可能重传事件,确保你的任务处理是幂等的(即同一文件处理多次结果相同),避免重复转码和入库。
- 存储策略:原始WAV文件在转码为MP3后应立即删除,以释放磁盘空间。MP3文件上传至对象存储后,本地副本也可考虑删除。制定一个生命周期策略,例如30天后将对象存储中的文件归档到更便宜的存储层。
5. 系统部署、调优与问题排查
将各个组件组装起来并稳定运行,还需要最后一步。
5.1 部署架构建议
对于中小规模使用,一个简单的单体服务器部署即可:
- 服务器:一台配置尚可的云服务器(如4核8G内存,50G SSD存储)。
- 服务部署:
- FreeSWITCH:直接运行在主机上。
- Redis、PostgreSQL、MinIO:可以使用Docker Compose一键部署,便于管理。
Recording-Processor(FastAPI + Celery Worker):使用Gunicorn运行FastAPI,使用Supervisor或Systemd管理Celery Worker进程。
对于更高可用性和扩展性的需求,可以考虑微服务化部署:
- FreeSWITCH集群:多台FS服务器通过SIP注册和负载均衡对外。
- 独立的ESL事件中继服务:专门负责连接所有FS实例的ESL,将事件统一发布到Kafka集群。
- 多个
Recording-ProcessorWorker:从Kafka消费事件,横向扩展处理能力。 - 高可用数据库和对象存储。
5.2 性能调优与监控
- FreeSWITCH调优:
conf/autoload_configs/switch.conf.xml:调整max-sessions(最大并发会话数)、sessions-per-second等参数。conf/autoload_configs/sofia.conf.xml:优化SIP Profile的rtp-timer-name、enable-100rel等设置,适应你的网络环境。- 录音使用
record_session而非record,前者性能更好。
- 网络与安全:
- 将FreeSWITCH的SIP端口(5060/5061)和RTP端口范围(16384-32768)在防火墙中打开。
- 务必配置强密码的SIP认证,避免被恶意注册攻击。
- 考虑将FreeSWITCH置于Nginx等反向代理之后,仅暴露必要的Web接口(如ESL需要严格限制访问IP)。
- 监控:
- FreeSWITCH:使用
fs_cli命令或通过ESL监控show calls、show channels、系统负载。 - 服务健康:为FastAPI服务添加
/health端点,使用Prometheus+Grafana监控Celery队列长度、任务处理耗时、系统资源。
- FreeSWITCH:使用
5.3 常见问题排查实录
在实际部署和运行中,你几乎一定会遇到下面这些问题:
问题1:电话能接通,但没有录音文件生成。
- 排查思路:
- 检查拨号计划:确认
record_session应用被正确执行。在fs_cli中运行show channels查看通话的当前状态和应用栈。 - 检查文件路径和权限:这是最常见的原因。手动在FreeSWITCH运行用户下,尝试在目标目录创建文件,看是否成功。使用
ls -la /var/lib/freeswitch/recordings/检查。 - 检查磁盘空间:
df -h。 - 查看FreeSWITCH日志:
tail -f /var/log/freeswitch/freeswitch.log,搜索record_session相关的错误信息。日志级别可以临时调整为DEBUG以获得更详细信息。
- 检查拨号计划:确认
问题2:录音文件有杂音、回音或声音很小。
- 排查思路:
- 回音(AEC):FreeSWITCH默认会启用软件回音消除。确保
conf/autoload_configs/echo.conf.xml被加载。如果问题严重,可能需要调整conf/autoload_configs/audio.conf.xml中的echo-cancel参数,或考虑使用硬件设备。 - 音量问题:在拨号计划中,可以在录音前使用
set命令调整输入/输出增益。例如:<action application="set" data="input_volume=+2.0"/>。 - 编码问题:确认通话双方协商的音频编码(如G.711, G.729, OPUS)。某些编码在丢包时音质会恶化。在SIP Profile中优先使用
OPUS或PCMU(G.711u)这类更抗丢包或保真度更高的编码。
- 回音(AEC):FreeSWITCH默认会启用软件回音消除。确保
问题3:ESL连接不稳定,经常断开。
- 排查思路:
- 网络与防火墙:确保ESL端口(默认8021)在本地环回或内部网络是通畅的,且没有被防火墙阻断。
- 心跳与超时:你的ESL客户端需要实现心跳机制,定期发送
api命令(如status)以保持连接活跃。同时,在FreeSWITCH的conf/autoload_configs/event_socket.conf.xml中,可以调整apply-inbound-acl(访问控制)和socket-timeout等参数。 - 错误处理与重连:在客户端代码中必须包含完善的异常捕获和断线重连逻辑。连接断开后应等待几秒后尝试重新连接。
问题4:后处理服务处理速度慢,队列堆积。
- 排查思路:
- 定位瓶颈:使用
top或htop查看是CPU(转码、ASR)还是I/O(文件读写、网络上传)成为瓶颈。 - 并行化:增加Celery Worker的数量。可以使用
celery -A celery_app worker --concurrency=4 -l info启动多个工作进程。 - 优化任务:将转码和ASR这两个最耗时的步骤拆分成独立的子任务,并行执行。或者,如果使用云ASR API,检查其是否有并发限制或延迟过高。
- 升级硬件:如果转码是瓶颈,考虑使用支持硬件加速(如Intel QSV或NVIDIA NVENC)的ffmpeg编译版本。
- 定位瓶颈:使用
这套开源方案从零搭建到稳定运行,需要你具备一定的Linux运维、网络和编程知识。它的优势在于完全自主可控、成本极低(主要是服务器和云ASR费用),并且可以根据你的业务需求进行无限定制。一旦跑通,它就是一个7x24小时在线的、可靠的电话录音助理,默默为你记录下每一通重要的对话。