十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于VC++语音电话开发实战:从音频采集到网络传输

基于VC++语音电话开发实战:从音频采集到网络传输 简介基于VC与MFC框架实现的语音电话网络编程示例面向具备C基础的网络编程学习者、MFC桌面应用开发者可作为理解实时语音通信原理的入门级参考。项目涉及UDP/TCP协议选型、G.711等音频编解码思路、多线程收发处理、CAsyncSocket套接字封装以及语音采集与播放的接口衔接覆盖从界面布局到网络传输的完整调用链。资源包共39个文件体积2.83MB包括5个h头文件、4个cpp源文件、资源脚本rc、图标ico、编译中间文件obj、工程文件dsw/dsp以及可直接运行的exe文件类型覆盖源码、资源与构建产物适合通过源码阅读结合运行效果来掌握MFC消息映射与Winsock编程方法。目前已有225人学习浏览其中modemDlg等模块展示了拨号界面与帮助对话框的实现对希望仿写简单语音通话工具或丰富网络编程课程设计素材的开发者具有参考价值。1. 项目在做什么先搞清楚你要的语音电话是哪一种看到“基于VC语音电话”这个标题很多人第一反应是“都什么年代了还做这个”。但实际接触过 Windows 桌面通讯、呼叫中心、企业内部调度系统的人都知道VC 写语音电话这件事一直没消失过。银行客服系统的软电话、小型呼叫中心的外呼程序、工厂内部的对讲终端底层不少还是 C 写的甚至很多项目是从 VC6.0 时代一路维护到现在的。做这个项目之前先别急着写代码得先想清楚一件事你说的“语音电话”到底是哪一种。不同方向的实现路径差别非常大选错了方向后面全是白做。1.1 三种典型场景先对号入座第一种是传统 PSTN 电话也就是我们常说的固话线路。这种场景一般通过 TAPITelephony API控制语音调制解调器、语音卡或者网关设备实现自动拨号、接听、挂断。适合做呼叫中心外呼系统、电话营销机器人、酒店叫醒服务这类需要跟真实电话号码打交道的项目。第二种是 VoIP 软电话走 SIP 协议通过互联网或者局域网打电话。手机上的网络电话、电脑上的软电话软件都属于这一类。做这种项目要处理的不是 TAPI而是 SIP 信令、RTP 音频流、NAT 穿透这些网络层的东西。第三种是局域网点对点语音对讲不涉及外部电话网也不一定走标准 SIP 协议就是两台机器之间直接传音频数据。这种最轻量适合做内部通讯、调度系统、教学示范也是很多人学习 VC 语音开发时最先接触的项目形态。我建议第一次做这个方向的朋友先从第三种或者第二种入手。第一种看似“打电话”很直观但 TAPI 设备的调试需要硬件环境而且现在能买到的语音卡/Modem 越来越少很容易卡在环境搭建上。局域网对讲或者简单 SIP 软电话一台电脑加一个麦克风就能跑起来学习成本低很多。1.2 为什么选 VC而不是 C# 或者 Java很多新手会问既然要写 Windows 程序用 C# 不是开发效率更高吗这里要分场景。如果是纯软件层面的软电话C# 确实可以甚至更快。但一旦涉及到跟硬件语音卡厂商 SDK 对接、需要直接操作系统底层音频接口、要求音频链路延迟可控的时候VC 的优势就体现出来了。语音通话是一个实时性要求很高的场景。音频数据从麦克风采集出来经过编码、网络传输、解码最后到扬声器播放整条链路的延迟要控制在几百毫秒以内才有正常的通话体验。C 可以直接操作用户态缓冲区没有托管语言的内存管理和 JIT 开销在线程调度、内存布局这些细节上有更大的控制空间。另外还有一个很现实的原因很多老的业务系统就是 VC 写的。银行、电力、物流这些行业的呼叫中心系统里有大量基于 VC6/VC2003 时代的遗留代码。新开发的模块要跟这些老系统集成你只能用 VC 接着写。这也是为什么“VC 2003 运行库”“VC Redistributable”这类词在热搜上居高不下的原因——不是大家想用老版本而是系统里跑着的程序就是那个时代编译的。1.3 整体架构与模块划分不管做上面哪种语音电话程序的基本骨架都差不多由四个核心模块组成。音频采集模块负责从麦克风拿到原始 PCM 数据。音频播放模块负责把收到的数据写到扬声器。信令控制模块负责拨号、接听、挂断等通话状态的维护。网络传输模块负责把音频数据封装成网络包发出去以及从网络接收数据。如果做 VoIP还要加一个编码解码模块把 PCM 数据压缩成 G.711、G.729 等格式来减少带宽占用。这四个模块之间通过线程和缓冲区协作。采集线程往发送队列里放数据发送线程从队列取数据发网络接收线程从网络收数据放进播放队列播放线程取数据写声卡。线程之间的协调是这类项目最容易出问题的部分后面会专门说。2. 环境和工具链准备运行库与开发环境那些坑VC 项目跟现在的 Python、JavaScript 项目不一样环境和依赖管理没那么梦幻但弄清楚了也不复杂。这里把最容易踩的坑先讲掉后面写代码的时候会顺畅很多。2.1 Visual Studio 版本与运行库匹配问题先明确一点用哪个版本的 Visual Studio 编译运行目标机器上就要有对应的“VC 运行库”Visual C Redistributable。这个运行库不是 Visual Studio 本身而是一组 DLL比如 msvcp140.dll、vcruntime140.dll程序启动时系统会去加载它们。这里有一个很容易搞混的点就是 x86 和 x64 的选择。如果你的语音电话程序编译成 32 位x86那么即使在 64 位的 Windows 系统上运行也只需要安装 x86 版的运行库。很多老项目都是 32 位的新员工接手后不管三七二十一装了 x64 的 vcredist程序还是报“缺少 VCRUNTIME140.dll”就是这个原因。热搜词里出现“VC 2003 运行库”“VC 2015 2022 x86”说明到现在还有大量老 VC 程序分布在各种 Windows 机器上。现实情况是老项目如果是用 VS2003 编译的目标机器大概率需要装 VS2003 对应的运行库。而 VS2015 到 VS2022 之间的运行库是二进制兼容的装一个最新的就能覆盖 2015 到 2022 所有版本编译出来的程序。解决运行库问题有两种思路。一种是在目标机器上安装对应的 vcredist 安装包最简单省事。另一种是在工程设置里把运行库改成静态链接/MT这样程序不依赖系统里的运行库 DLL拷过去就能跑代价是 exe 体积变大几十 MB。我给客户做外呼工具的时候一般会优先静态链接因为目标电脑环境不可控少一个外部依赖就少一个故障点。但要注意静态链接涉及 VC 运行库的分发许可问题个人使用和公司内部使用一般没问题要对外发布商业软件的话建议仔细看一下微软的许可条款。2.2 搭建项目骨架MFC 还是 Win32Visual Studio 里新建 VC 工程模板通常会问你要不要用 MFC。MFC 是对 Win32 API 的 C 封装它把窗口、消息循环、对话框这些脏活累活都包好了开发效率高很多。做语音电话这种带界面的程序我用 MFC 的对话框程序作为骨架因为拨号按钮、来电显示、通话状态这些界面元素用对话框模板拖一拖就出来了。不推荐用纯 Win32 手写窗口过程函数除非你对 Windows 消息机制非常熟悉否则光是窗口消息分发、控件初始化就要写一大堆代码且容易出错。也不推荐一上来就上 Qt虽然 Qt 跨平台很香但引入一个 GUI 框架之后音频设备的接入方式、线程模型都要跟着框架走对新手不友好调试起来更复杂。考虑到很多语音电话项目是在老电脑、或客户环境不明的 Windows Server 上跑MFC 还有一个好处它随 Visual Studio 安装时配置好发布时也可以把 MFC DLL 一起打包不依赖网络安装。这一点在隔离网环境里非常重要。2.3 音频与通讯依赖库的选型音频采集和播放优先用 Windows 自带的 waveIn/waveOut API。这套 API 很老但稳定、简单、足够用。它把音频设备的操作封装成打开设备、准备缓冲区、提交缓冲区、等待回调四个步骤逻辑非常清晰。相比于 DirectSound、WASAPI 这些更底层的接口wave 系列 API 代码量少概念直观非常适合电话语音这种低采样率、低延迟要求的场景。信令控制方面如果做局域网对讲不需要第三方库自己定一个简单的信令协议就行。如果做 SIP 软电话不建议自己从零实现 SIP 协议栈工作量太大、坑太多。可以选 PJSIP 这个开源库它同时处理了 SIP 信令、媒体传输、音频编解码C 接口也友好。或者用 eXosip比 PJSIP 更轻量一些但功能也少一些。商用方案可以找国内语音厂商的 OCX 组件但收费且文档质量参差不齐我一般先考虑开源库。3. 核心功能逐块实现从录音到放音从拨号到通话下面进入正题把核心功能逐个拆开讲。我这里以“局域网点对点语音通话”为例子来写因为它最不依赖外部设备一台电脑也能模拟测试。加上 TAPI 拨号的实现思路这样不管你做哪个方向都能对上。3.1 音频采集用 waveIn 接入麦克风先看一段简单的音频初始化代码WAVEFORMATEX wfx {0}; wfx.wFormatTag WAVE_FORMAT_PCM; wfx.nChannels 1; // 单声道 wfx.nSamplesPerSec 8000; // 采样率 8kHz wfx.wBitsPerSample 16; wfx.nBlockAlign wfx.nChannels * wfx.wBitsPerSample / 8; wfx.nAvgBytesPerSec wfx.nSamplesPerSec * wfx.nBlockAlign; HWAVEIN hWaveIn NULL; waveInOpen(hWaveIn, WAVE_MAPPER, wfx, (DWORD_PTR)WaveInProc, 0, CALLBACK_FUNCTION);这里几个参数需要解释一下。为什么采样率选 8000 Hz因为人说话的语音频带大部分能量集中在 300Hz 到 3400Hz8kHz 采样率是电话语音的标准能完整覆盖这个频带同时数据量小。算一下就知道8kHz × 2 字节16bit × 1 声道 16000 字节/秒。如果选 44.1kHz 的 CD 音质每秒数据量是 176400 字节网络带宽要求一下子高了 10 倍多对电话语音来说完全没必要。第二个要理解的是缓冲区的设计。waveIn 是异步采集的你需要准备若干个缓冲区交给设备设备采集满一个缓冲区就会回调通知你。我习惯准备 4 个缓冲区每个缓冲区存放 20ms 的音频数据也就是 16000 ÷ 1000 × 20 320 字节。这样设计的核心目的是控制延迟如果缓冲区设成 200ms那语音从嘴边传到对方耳朵里至少延迟 200ms对话体验已经很难受了20ms 则基本感觉不到。采集回调里做的事情本质上是“拿数据、发数据、还缓冲区”三步void CALLBACK WaveInProc(HWAVEIN hwi, UINT uMsg, DWORD_PTR dwInstance, DWORD_PTR dwParam1, DWORD_PTR dwParam2) { if (uMsg WIM_DATA) { PWAVEHDR pHeader (PWAVEHDR)dwParam1; // pHeader-lpData 指向采集到的 PCM 数据 // pHeader-dwBytesRecorded 是实际数据长度 SendVoicePacket(pHeader-lpData, pHeader-dwBytesRecorded); // 把缓冲区重新交还给采集设备开始下一次采集 waveInAddBuffer(hwi, pHeader, sizeof(WAVEHDR)); } }这里有一个新手经常忽略的点回调函数里绝对不能做耗时操作比如加锁等待、网络阻塞发送。因为音频回调线程对实时性要求极高卡顿几十毫秒就会导致声音断续。正确做法是把数据拷贝到一个线程安全的队列里由另一个发送线程去取让回调函数立刻返回。3.2 音频播放用 waveOut 输出到扬声器播放侧跟采集侧是对称的HWAVEOUT hWaveOut NULL; waveOutOpen(hWaveOut, WAVE_MAPPER, wfx, (DWORD_PTR)WaveOutProc, 0, CALLBACK_FUNCTION);播放的关键在于你不能在回调里直接把收到的网络数据丢给声卡。声卡是按照自己的节奏消费数据的但网络包的到达是不均匀的可能这一毫秒来了 5 个包下一毫秒一个都不来。如果不做缓存声卡刚写完一个缓冲区就没数据了俗称“欠载”表现出来就是爆音、卡顿。解决办法是加一个播放缓冲区队列专业叫法是抖动缓冲Jitter Buffer。我一般维护一个 60ms 到 120ms 的播放缓冲收到网络数据先入队播放线程从队里取数据写声卡。这样网络抖动造成的延迟波动被缓冲吸收掉播放就平稳了。代价是引入 60ms 到 120ms 的额外延迟但对语音通话来说完全可接受。播放回调的处理逻辑是当 waveOut 写完一个缓冲区并归还给你时从接收队列里取出下一段数据拷贝进去再次 waveOutWrite。如果队列里暂时没数据我会填充一段静音数据进去而不是让声卡干等。静音填充的做法比空转更稳定能避免声卡状态异常。这个经验的来源是某次项目上线后我发现对方说话时断时续最后定位到是播放侧在数据没到齐时直接把缓冲区丢弃了导致声卡被打断。后来改成填静音问题就消失了。3.3 网络传输用 Winsock 承载语音数据语音传输首选 UDP。很多人不理解为什么不用 TCP因为 TCP 会重传丢掉的包重传机制适合文件传输但对实时语音不合适——语音包晚到 300ms 就没有意义了重传回来的包只会打乱播放顺序。UDP 丢包了就丢了最多那一小段声音有点瑕疵影响远比重传小。发送端代码很简单就是从一个线程安全队列里取采集到的音频数据然后 sendtoSOCKET udpSock socket(AF_INET, SOCK_DGRAM, 0); sockaddr_in peerAddr; peerAddr.sin_family AF_INET; peerAddr.sin_port htons(6000); inet_pton(AF_INET, 192.168.1.100, peerAddr.sin_addr); // 发送线程循环 while (running) { if (SendQueue.Pop(pcmData, 320)) // 取出 20ms 的音频数据 { sendto(udpSock, pcmData, 320, 0, (sockaddr*)peerAddr, sizeof(peerAddr)); } }但这里有个很经典的问题UDP 不保证包顺序。网络环境差的时候后发的包可能先到。如果直接按收到顺序写入播放缓冲区那声音就是乱的。解决方法是给每个音频包加一个序号接收端根据序号重排或者简单丢弃乱序包。这个序号放在自定义包头里我通常用一个两字节的序号字段从 0 到 65535 循环。如果做的是 SIP 软电话那数据传输要用标准 RTP 协议RTP 头部本身就带了序号和时间戳不需要自己设计包头。这就是用标准协议的好处——能跟其他 SIP 设备互通而不是只能跟自己家软件对话。3.4 拨号与接听TAPI 与 SIP 两种路线局域网对讲模式下可以不要拨号开机即通话。但如果要做成真正的“语音电话”必须加信令。TAPI 路线的拨号核心是三个调用// 初始化 TAPI lineInitializeEx(hLineApp, hInstance, LineCallback, NULL, dwNumDevs, dwVersion, lineInitExt); // 打开一个线路设备 lineOpen(hLineApp, dwDeviceID, hLine, dwVersion, LINEAPI_VERSION, 0, 0, LINECALLPRIVILEGE_MONITOR); // 发起呼叫 lineMakeCall(hLine, hCall, 10086, 0, NULL);这段代码看起来简洁但真实使用时会发现难点不在调用本身而在回调消息的处理。TAPI 是事件驱动的拨号后你要在 LineCallback 回调里等 LINE_REPLY 确认拨号指令成功等 LINE_CALLSTATE 通知你对方已振铃、已接听、已挂断。很多引入 TAPI 的项目坑都坑在对这些状态机的理解不够比如没有处理 LINE_CALLSTATE_DISCONNECTED 导致通话挂断后程序不知道去清理资源。SIP 路线的信令实现要复杂得多INVITE、100 Trying、180 Ringing、200 OK、ACK、BYE每个消息都有状态转换。这就是我之前说直接用 PJSIP 的原因。PJSIP 内部已经把这些状态机处理好了你只要实现几个回调比如说“得到一个来电”、“对端接听了”、“通话结束了”等等然后调用 pjsua_call_make_call、pjsua_call_hangup 这样的接口就行。我用 PJSIP 做过一个软电话客户端整个信令部分的代码量大概只有自己实现 SIP 栈的五分之一而且正确率高得多。3.5 界面与线程UI 千万别卡死MFC 程序里所有界面操作必须在主线程也就是 UI 线程做。录音回调线程、网络接收线程都不应该直接操作控件。比如来电显示你不可能在网络线程里直接 SetDlgItemText那样轻则界面卡顿重则直接崩溃。正确做法是让工作线程往主线程发消息。MFC 里可以 PostMessage 一个自定义消息把数据打包在 WPARAM 和 LPARAM 里传到 UI 线程。或者定义一个线程安全的全局变量保留来电状态再用定时器让 UI 线程周期性检查并刷新界面。我在实际项目中更偏向 PostMessage因为定时器轮询会引入不必要的延迟和开销。还有一个容易被忽略的点waveInOpen 回调模式选择。回调既可以是 CALLBACK_FUNCTION 也可以是 CALLBACK_WINDOW。如果用 CALLBACK_FUNCTION回调线程是系统分配的你把数据投递给业务线程即可。如果用 CALLBACK_WINDOW回调消息会进入你的对话框消息循环这种情况下回调自动就回到 UI 线程了省去了跨线程同步的麻烦。但 CALLBACK_WINDOW 有个隐患如果 UI 线程忙于处理其他消息音频回调可能得不到及时响应导致采集缓冲区不能及时归还声音出问题。所以我更倾向于 CALLBACK_FUNCTION 加独立业务线程的模式这样音频处理不受 UI 操作影响。4. 常见问题与排查技巧实录做语音电话这类软实时系统调试过程比写代码更考验人。这里把我自己踩过和帮别人排查过的高频问题整理成速查表每个问题都附上排查思路方便你照着查。问题现象常见原因排查/解决建议程序启动报缺少 DLL目标机器未装 VC 运行库或版本不匹配查看报错 DLL 名安装对应 vcredist64 位系统跑 32 位程序需装 x86 版采集到数据但对方听不到麦克风设备选错、录音权限未开启、采样率不匹配先用系统录音机测试麦克风再用 waveInGetDevCaps 枚举设备确认 WAVE_MAPPER 选中了正确设备声音断续、卡顿缓冲区太小或网络抖动过大增大采集/播放缓冲区到 40ms-60ms接收端增加抖动缓冲爆音、杂音播放缓冲区被反复清空重建数据格式不匹配播放侧缺数据时填充静音不要丢弃缓冲区确认收发双方采样率、位深、声道一致声音延迟很大缓冲区过多或播放队列积压严重检查音频链路缓冲总时长控制在 200ms 内缩短抖动缓冲到 60ms 左右通话一段时间后死机线程不安全操作或缓冲区未正确释放用 VS 的调试器查异常线程检查 waveInAddBuffer/waveOutWrite 是否在正确的线程调用拨号后无反应TAPI/SIP 信令异常设备或远端未配置先确认对方地址可通ping抓包看信令消息是否有响应检查本地音频设备是否被占用只能单向通话一侧采集或播放未初始化成功NAT/防火墙阻断单向 UDP分别检查两侧日志确认初始化返回值先在内网环境测试排除网络因素上面有几条我要单独展开说因为它们反复出现且坑得很隐蔽。4.1 运行库缺失与 VC 版本混乱我接手过一个维护项目程序在开发机上跑得好好的部署到客户服务器上就报“无法启动此程序因为计算机中丢失 msvcr120.dll”。查下来发现程序是用 VS2013 编译的客户机器只装了 VS2015 的运行库。VS2015 的 vcredist 并不包含 VS2013 的 msvcr120.dll这个程序恰恰依赖的是旧版运行库。这类问题最常见的处理方式有两个。一是安装对应版本的运行库比如 msvcr120.dll 对应 VS2013 的 vcredist_x86.exe。一个是把项目升级到新环境重新编译比如把工程迁移到 VS2019/2022这样只需要目标机器装 2015-2022 版的统一运行库。如果是老项目尽量别动编译器版本因为老代码可能在新的编译选项下出现新的警告或错误迁移成本不低。我一般先尝试装运行库解决再评估迁移时机。4.2 录音无声或音量过小录音无声的排查顺序是先排除硬件再排查软件。先用系统自带的录音机录一段试试如果系统录音也无声那是麦克风权限、驱动或硬件的问题代码再怎么调也没用。如果系统录音正常程序录不到那大概率是 waveInOpen 时打开的设备和系统默认设备不一致或者音频格式设置了系统不支持的参数。可以枚举一下系统里的音频输入设备WAVEINCAPS caps; UINT devNum waveInGetNumDevs(); for (UINT i 0; i devNum; i) { waveInGetDevCaps(i, caps, sizeof(caps)); // 打印 caps.szPname确认设备名称 }还有一种情况是音量过小。有的麦克风阵列或者 USB 耳麦的采集增益很低程序里采集到的是有效但幅度很小的数据。这时候可以在程序里做 AGC自动增益控制把音量小的片段放大。简单做法是扫描一段数据的峰值如果峰值低于某个阈值就整体乘一个系数。不用做得太复杂电话语音场景下音量稳定比音质更重要。4.3 放音爆音、回声问题爆音的本质是播放数据流出现了不连续。我遇到的爆音案例中有八成是缓冲区管理不当造成的。典型问题是接收线程每收到一个网络包就调用一次 waveOutWrite而声卡写完一个缓冲区要 20ms如果网络包到达间隔不均匀就会出现声卡缓冲区用完了但新数据还没准备好的情况。解决方法是前面说的维护播放队列并且确保队列里有足够的预存数据再启动播放。回声则是 VoIP 通话特有的问题尤其在使用外放音箱的时候。对方的声音从你的音箱出来又被你的麦克风采集到传回给对方对方就听到了自己的回声。解决思路有两条硬件上让用户戴耳机软件上做回声消除AEC。Windows 的音频 API 本身不带 AEC需要自己集成第三方库比如 WebRTC 的 AEC 模块就提供了这个能力。做局域网对讲可以不处理回声直接提示用户戴耳机很多内部工具就是这么干的。但做商业软电话不带 AEC 基本没法交付。5. 一点实操经验分享项目做到后面我最大的体会是语音电话这个项目代码量其实不大核心代码可能一两千行就搞定了真正花时间的全是调试和对边角情况的处理。一个特别实用的调试技巧是先用本地回环测试也就是把自己的电脑同时当作发送方和接收方程序跑起来后自己对着麦克风说话然后从扬声器听回放。如果回环正常但双机通话有问题那问题在网络传输如果回环都不正常那就是音频链路的问题跟网络无关。这个技巧能帮你快速缩小问题范围我几乎每个语音项目都从这开始。另一个技巧是把采集到的音频数据保存成 WAV 文件。在回调里同时把原始 PCM 数据写入一个文件出问题时直接听录音文件比你在代码里跟踪数据要直观得多。WAV 文件不过是在 PCM 数据前面加 44 字节的文件头算好字节数填进去就行。这个习惯帮我解决了很多“对方说声音不清楚但代码看不出问题”的疑难杂症。还有一点如果项目后续要扩展建议尽早引入语音编解码和降噪处理。裸的 PCM 传输在局域网里没问题但跨公网时带宽和丢包会导致语音质量明显下降。G.711 编码可以把 16kbps 的 PCM 数据压到 8kbps稍微加点处理还能带丢包补偿。PJSIP 这类库自带这些能力用它们比自己造轮子省太多事了。最后再提醒一次VC 语音电话项目的关键不在语法而在于线程模型、缓冲区管理和状态机。把这三点想清楚代码怎么写都不会出大问题。本文还有配套的精品资源点击获取
返回列表