1. 摘要
本文介绍了基于WaveX低级音频API采集音频及实时播放的技术。并对音频实时性和连续性作了比较深入的分析。利用双/多缓冲技术和网络拥塞控制策略可很好的控制音频的实时性和连续性。
2. 背景介绍
WINDOWS下音频的采集,播放有三种模式:
1)通过高级音频函数、媒体控制接口MCI[1、2]设备驱动程序;
2)低级音频函数MIDI Mapper、低级音频设备驱动(WaveXAPI);
3)利用DirectX中的DirectSound;
使用MCI的方法极其简便,灵活性较差;使用低级音频函数的方法相对来说难一点,但是能够对音频数据进行灵活的操控;而采用DirectSound的方法,控制声音数据灵活,效果比前二者都好,但实现起来是三者中最难的。
低层音频服务及重要的数据结构低级音频服务控制着不同的音频设备,这些设备包括 WAVE,MIDI和辅助音频设备。低级音频服务包括如下内容:
(1)查询音频设备;
(2)打开和关闭设备驱动程序;
(3)分配和准备音频数据块;
(4)管理音频数据块;
(5)应用MMTIME结构;
(6)处理错误。
WaveX低级音频函数的相关声明和定义在mmsystem.h头文件和Winmm.lib库中。所以如果程序中用到这些函数,必须包含mmsystem.h这个头文件,同时导进Winmm.lib库。如下:
- #include "mmsystem.h"
- #pragma comment(lib,"Winmm.lib")
双/多缓冲技术可以很好的实现声音的快速连续采集和实时顺畅播放。采集声音时,缓冲满了会有一个消息,程序在响应这个消息需要几毫秒~几十毫秒甚至更多的时间,假设为Xms,如果只使用一个缓冲,程序必须在响应完该消息才再次采集声音,那么在这Xms的时间里,没有采集到任何声音;声音的播放也是一样的道理,这样声音就会不连续。因此双缓冲或多缓冲技术是必要的,让输入和输出设备可以循环使用这些缓冲,当程序在响应某块缓冲数据已满或播放完毕消息时,声卡可以继续往下一块缓冲添加数据或播放下一块缓冲的数据,如此循环保障声音的连续性。
3. 相关数据结构
声音在采集(录音)和播放的时需要有一些统一的格式,包括音频格式类型,声道,采样率等信息。下面的数据结构具体描述了该格式:
- typedef struct tWAVEFORMATEX
- {
- WORD wFormatTag; /* format type */
- WORD nChannels; /* number of channels (i.e. mono, stereo...) */
- DWORD nSamplesPerSec; /* sample rate */
- DWORD nAvgBytesPerSec; /* for buffer estimation */
- WORD nBlockAlign; /* block size of data */
- WORD wBitsPerSample; /* number of bits per sample of mono data */
- WORD cbSize; /* the count in bytes of the size of */
- /* extra information (after cbSize) */
- } WAVEFORMATEX, *PWAVEFORMATEX, NEAR *NPWAVEFORMATEX, FAR *LPWAVEFORMATEX;
其中,wFormatTag是音频格式类型,nChannels是声道数,nSamplesPerSec是采样频率,nAvgBytesPerSec是每秒钟的字节数,nBlockAlign是每个样本的字节数,wBitsPerSample是每个样本的量化位数,cbSize是附加信息的字节大小。
在打开声卡输入和输出设备之前,必须对音频的相关参数进行设置。在后面章节中将给出WAVE_FORMAT_PCM格式音频的详细参数设置。
音频数据块有一个头结构,这个结构包含了音频数据缓冲的地址,大小,已录音数据大小等信息和其他各种控制标志。这个结构适用于音频的输入(录音)和输出(播放)缓冲中。下面是该结构的详细信息:
- typedef struct waveformat_tag {
- WORD wFormatTag; /* format type */
- WORD nChannels; /* number of channels (i.e. mono, stereo, etc.) */
- DWORD nSamplesPerSec; /* sample rate */
- DWORD nAvgBytesPerSec; /* for buffer estimation */
- WORD nBlockAlign; /* block size of data */
- } WAVEFORMAT, *PWAVEFORMAT, NEAR *NPWAVEFORMAT, FAR *LPWAVEFORMAT;
其中,lpData是指定的缓冲块地址,dwBufferLength是指定的缓冲块大小,dwBytesRecorded是已录音数据大小,dwUser是用户数据,dwFlags是控制标志,表明缓冲的使用状态,dwLoops是音频输出时缓冲数据块循环的次数,lpNext和reserved是系统保留数据。在程序实现时,通过设置或修改这个结构的相关参数来实现对音频输入和输出缓冲区的控制。
程序中定义了一个队列结构,用来存储网络中接收到的音频数据,其结构如下:- struct CAudioOutData
- {
- short *lpdata;
- DWORD dwLength;
- };
- struct CAudioOutData m_AudioDataOut[50];
其中,lpdata是数据块地址,dwLength是数据块大小。通过调整m_AudioDataOut下标实现队列的循环过程。
4. 参数配置
声卡输入和输出的音频属性可定义如下:
- m_waveformt.wFormatTag = WAVE_FORMAT_PCM;
- m_waveformt.nChannels = 1;
- m_waveformt.nSamplesPerSec = 8000;
- m_waveformt.wBitsPerSample = 16;
- m_waveformt.cbSize = 0;
- m_waveformt.nBlockAlign = 2;
- m_waveformt.nAvgBytesPerSec = 16000;
- nBlockAlign = nChannels * wBitsPerSample / 8 ;
- nAvgBytesPerSec = nSamplesPerSec * nBlockAlign ;
- pWaveHdr->lpData = pDataBuf; // 指定缓冲的地址;
- pWaveHdr->dwBufferLength = bufferLength; //指定缓冲的大小;
- pWaveHdr->dwBytesRecorded = 0 ;
- pWaveHdr->dwUser = 0 ;
- pWaveHdr->dwFlags = 0 ;
- pWaveHdr->dwLoops = 1 ;
- pWaveHdr->lpNext = NULL;
- pWaveHdr->reserved = 0;
5. 基本操作流程
调用WaveX低级音频函数API启动声卡录音的基本操作步骤如下图所示:
- 打开录音设备: waveInOpen
- ↓
- 为录音设备准备缓存: waveInPrepareHeader
- ↓
- 为输入设备增加缓存: waveInAddBuffer
- ↓
- 启动录音: waveInStart
- ↓
- 清除缓存: waveInUnprepareHeader
- ↓
- 停止录音: waveInReset
- ↓
- 关闭录音设备: waveInClose
在这个过程中,会产生很多 WM_WIM_*** 格式的 WINDOWS 消息。程序通过捕获这些消息对缓存,数据和设备进行处理,具体可见后面章节。录音设备打开时,可以指定消息的响应方式:回掉函数,线程 ID , WINDOWS 窗口句柄或事件句柄等。
- MMRESULT waveInOpen(
- LPHWAVEIN phwi, //输入设备句柄
- UINT uDeviceID, //输入设备ID
- LPWAVEFORMATEX pwfx, //录音格式指针
- DWORD dwCallback, //处理消息的回调函数或窗口句柄,线程ID等
- DWORD dwCallbackInstance, //通常为0
- DWORD fdwOpen //处理消息方式的符号位
- );
- 打开输出设备: waveOutOpen
- ↓
- 为输出设备准备缓存: waveOutPrepareHeader
- ↓
- 写数据导输出设备缓存: waveOutWrite
- ↓
- 清除输出缓存: waveOutUnprepareHeader
- ↓
- 停止输出: waveOutReset
- ↓
- 关闭输出设备: waveOutClose
图5.2 播放流程
同录音一样,在音频输出过程中也有一系列的消息,程序通过捕获这些消息对缓存,数据和设备进行处理。具体操作见后面章节。
6. 消息及处理
WINDOWS下提供消息映射实现事件的处理。低级音频函数处理声音数据块也正要归功于WINDOWS的消息映射机制。
图6.1 和6.2分别描述了声卡录音和播放过程中产生的消息:
- WM_WIM_OPEN
- │ ↘
- │ 音频输入设备打开消息
- ↓
- WM_WIM_DATA
- │ ↘
- │ 缓冲录满或停止录音消息
- ↓
- WM_WIM_CLOSE
- │ ↘
- ↓ 音频输入设备关闭消息
- WM_WOM_OPEN
- │ ↘
- │ 音频输出 设备打开消息
- ↓
- WM_WOM_DONE
- │ ↘
- │ 缓冲播放完或停止输出消息
- ↓
- WM_WOM_CLOSE
- │ ↘
- ↓ 音频输出设备关闭消息
在打开音频输入或输出设备消息处理中,可对一些变量进行初始化;在WM_WIM_CLOSE消息的处理中主要是调用WaveInUnprepareHeader释放输入设备对应的缓存和其他资源;在WOM_CLOSE消息的处理中主要是调用WaveOutUnprepareHeader释放输出设备对应的缓存和其他资源;在WM_WIM_DATA消息处理中,处理完录入的数据后需要重新为输入设备添加缓冲;在WM_WIM_DATA消息处理中,首先清空已经播放的数据,调整循环队列中接收音频指针和播放音频指针,然后从该队列中拷贝数据到输出缓存中。
7. 程序结构
系统的功能是实现在局域网内的点对点实时音频通信。音频的网络传输采用UDP方式。
程序中为输入设备准备了两块缓冲,输出设备的缓冲块数可以通过修改宏进行调整,但至少是两块。程序使用多线程技术实现声音的采集发送和接收播放。总体结构如下所示:
- 1 程序启动
- 2 ↓
- 3 设置音频属性
- 4 │
- 5 ↙ ↘
- 6 启动录音 启动音频包接收
- 7 ↓ ↓
- 8 发送音频包 启动播放
程序总体结构很简单,大体可以分为两部分:一是录音和发送,二是接收和播放。
(一) 录音发送部分的流程如下:
- 打开音频输入设备(录音)
- ↓
- 为输入设备准备两块缓存
- ↓
- 启动录音
- │
- ┌─────────────→│
- │ ↙ ↘
- │ 捕捉到消息:WM_WIM_DATA 捕捉到消息:WM_WIM_CLOSE
- │ ↓ ↓
- │ 对已录数据进行G721编码 释放输入设备所有缓存
- │ ↓ ↓
- │ 发送 编码后数据 关闭音频输入设备,结束
- │ ↓
- │ 为输入设备重新指定缓存
- │ │
- └───────┘
部分源码:
1)打开音频输入设备源码为:
- waveInOpen(&hWaveIn,
- WAVE_MAPPER,
- &m_waveformin,
- (DWORD)waveInProc,
- NULL,
- CALLBACK_FUNCTION);
其中,hWaveIn为音频输入句柄,WAVE_MAPPER表示让系统选择录音的声卡,m_waveformin是音频格式,waveInProc是消息处理函数名,CALLBACK_FUNCTION表示以函数调用的方式处理响应录音过程中的消息。
2)为输入设备添加缓冲的源码为:
- // 为音频输入准备两个缓存:pBuffer1,pBuffer2, 大小为BUFFER_SIZE。
- // hWaveIn为音频输入句柄
- pWaveHdr1->lpData = (LPTSTR)pBuffer1;
- pWaveHdr1->dwBufferLength = BUFFER_SIZE;
- pWaveHdr1->dwBytesRecorded = 0;
- pWaveHdr1->dwUser = 0;
- pWaveHdr1->dwFlags = 0;
- pWaveHdr1->dwLoops = 1;
- pWaveHdr1->lpNext = NULL;
- pWaveHdr1->reserved = 0;
- // 准备头结构1
- waveInPrepareHeader(hWaveIn,pWaveHdr1,sizeof(WAVEHDR)) );
- pWaveHdr2->lpData = (LPTSTR)pBuffer2;
- pWaveHdr2->dwBufferLength = BUFFER_SIZE;
- pWaveHdr2->dwBytesRecorded = 0;
- pWaveHdr2->dwUser = 0;
- pWaveHdr2->dwFlags = 0;
- pWaveHdr2->dwLoops = 1;
- pWaveHdr2->lpNext = NULL;
- pWaveHdr2->reserved = 0;
- // 准备头结构2
- waveInPrepareHeader(hWaveIn,pWaveHdr2,sizeof(WAVEHDR)) );
- // 添加缓存
- waveInAddBuffer (hWaveIn, pWaveHdr1, sizeof (WAVEHDR));
- waveInAddBuffer (hWaveIn, pWaveHdr2, sizeof (WAVEHDR));
3)WM_WIM_DATA消息处理中为输入设备添加缓存源码为:
- // dwParam1为消息处理函数参数
- waveInPrepareHeader (hWaveIn, (PWAVEHDR)dwParam1, sizeof (WAVEHDR));
- waveInAddBuffer (hWaveIn, (PWAVEHDR)dwParam1, sizeof (WAVEHDR));
WM_WIM_CLOSE消息处理中释放缓存源码为:
- waveInUnprepareHeader (hWaveIn, pWaveHdr1, sizeof (WAVEHDR));
- waveInUnprepareHeader (hWaveIn, pWaveHdr2, sizeof (WAVEHDR));
(二)音频接收播放流程如下所示:
- 打开音频输出设备(播放)
- ↓
- 为输出设备准备n块缓存 (n >= 2)
- │
- ↙ ↘
- ↙ ↘
- ──── ─────
- ↓ ↓←────┐
- 启动网络 循环队列中音频 │
- 音频数据 接收指针位置 │
- 接收线程 是否大于1 ──┘
- │ │ N
- ┌──→│ │Y
- │ ↓ ↓
- │ 接收数据 ┌─────→启动播放
- │ 丢包处理 │ │
- │ │ │ ↙ ↘
- │ │ │ ↙ ↘
- │ ↓ │ ↓ ↓
- │调用G721对接收 │ 捕捉到消息: 捕捉到消息:
- │的数据进行解码 │ WM_WIM_DATA WM_WIM_CLOSE
- │ │ │ ↓ ↓
- │ ↓ │调整音频播放指针 释放所有音频
- │ 将解码后数据 │在循环队列中位置 输出设备缓存
- │ 拷进循环队列 │ ↓ ↓
- │ │ │从循环队列中拷贝 关闭音频输出
- └───┘ │ 数据到输出缓存 设备,结束
- │ ↓
- └───┘
部分源码:
打开音频输出设备:
- waveOutOpen(&hWaveOut,
- WAVE_MAPPER,
- &m_waveformout,
- (DWORD)waveOutProc,
- NULL,
- CALLBACK_FUNCTION);
hWaveOut是输出设备句柄;WAVE_MAPPER表示让系统选择播放声卡;m_waveformout表示输出音频格式;waveOutProc是音频输出消息处理函数名;CALLBACK_FUNCTION表示以回掉函数的方式响应音频输出过程的消息。
为音频输出准备缓冲并启动音频输出源码:
- for (int i=0;i<BufferNum;i++) // BufferNum 为输出缓冲块数
- { // outBuffer[i]是每一块缓冲区的 首地址,为short类型
- pWaveHdrOut[i]->lpData = (LPTSTR)outBuffer[i];
- pWaveHdrOut[i]->dwBufferLength = OUTSIZE*sizeof(short);
- pWaveHdrOut[i]->dwBytesRecorded = 0 ;
- pWaveHdr->lpNext = NULL ;
- pWaveHdr->reserved = 0 ;
- m_waveformt.nAvgBytesPerSec = 16000 ;
- pWaveHdrOut[i]->dwUser = 0 ;
- pWaveHdrOut[i]->dwFlags = 0 ;
- pWaveHdrOut[i]->dwLoops = 1 ;
- pWaveHdrOut[i]->lpNext = NULL ;
- pWaveHdrOut[i]->reserved = 0 ;
- }
- while(1)
- { // nReceive 是网络音频接收指针在循环队列中的位置
- if( nReceive > 1 ) // 当接收到的数据大于1帧时才启动声卡输出
- {
- for(int i=0;i<BufferNum;i++)
- { // 启动音频输出所有缓冲区块
- waveOutPrepareHeader(hWaveOut,pWaveHdrOut[i],sizeof(WAVEHDR));
- waveOutWrite(hWaveOut,pWaveHdrOut[i],sizeof(WAVEHDR));
- }
- break;
- }
- Sleep(100);
- }
- // dwParam1为消息处理函数参数;m_AudioDataOut是 音频接收循环队列首地址
- // nAudioOut为音频播放指针在循环队列中位置
- memcpy(
- ((PWAVEHDR)dwParam1)->lpData,
- (char )m_AudioDataOut[nAudioOut].lpdata,
- m_AudioDataOut[nAudioOut].dwLength*sizeof(short)
- );
- waveOutWrite(hWaveOut,(PWAVEHDR)dwParam1,sizeof(WAVEHDR));
- for(int i=0;i<BufferNum;i++) // 释放资源
- {
- //释放输出头缓冲
- waveOutUnprepareHeader(hWaveOut,pWaveHdrOut[i],sizeof(WAVEHDR));
- if( pWaveHdrOut[i] )
- {
- free(pWaveHdrOut[i]);
- pWaveHdrOut[i] = NULL;
- }
- if( outBuffer[i] ) // 释放输出缓冲
- {
- free(outBuffer[i]);
- outBuffer[i] = NULL;
- }
- }
8. 网络拥塞控制机制
从网络中接收到的音频数据解码后存放在循环队列中,队列中缓存块数为50,每块大小为400Bytes(网络音频数据一帧为100B,解码后为400B);声卡播放音频需要从循环队列中取数据,令接收指针在循环队列中的位置为nR,播放指针为nP,两者之间的关系如图8.1所示:
图8.1 循环队列结构
nP 和nR指向的位置一开始都是0。如果同时启动网络接收和音频播放的话就会造成同一块内存的读写冲突,导致程序崩溃,所以程序中当nR>1时才启动音频播放。
nR和nP的移动是异步的。当接收到一帧网络音频数据时nR =(nR+1)%50;而nP的移动需要视网络状态好坏而定。通过协调两者的移动步伐达到网络拥塞控制的目的。具体如下:
1)网络正常,即nR和nP的差距在2~8之间,包括2和8,则nP = (nP+1)%50;
2)网络繁忙,即nR指针移动过满,导致nR-nP < 2,则nP保持不动,下一次播放的数据为空数据;
3)网络较好,且因某些原因声卡输出慢了,即nR-nP > 8,则np = (nP+abs(nR-nP)%50-5)%50 ;通过丢弃一些数据帧以保证声音的实时性,否则会造成声音延时过大,而且随着时间推移会越来越大。
9. 系统测试
语音通信主要测试以下几个方面:
1)实时性:话音是否有延时;
2)连续性:话音是否连续,中间是否会断续;
3)稳定性:话音是否会丢失,若会,丢失的是否严重;
程序中某些参数是固定的:音频每次采集的大小为400B,编码后为100B;接收音频循环队列块数为50。可调整的是循环队列每块缓存大小,声卡输出缓冲大小(两者的大小是一样的),以及声卡输出缓冲数目。假设缓存大小为BUFFER,缓存块数为BUFFERNUM,通过测试有以下结果:
BUFFER(Bytes) | BUFFERNUM | 话音效果 | ||
实时性 | 连续 | 稳定 | ||
400 | 2 | 好 | 不连续 | 稳定 |
400 | 4 | 好 | 有点不连续 | 稳定 |
400 | 8 | 好 | 一般 | 稳定 |
400 | 16 | 好 | 好 | 稳定 |
800 | 2 | 好 | 有点连续 | 稳定 |
800 | 4 | 好 | 一般 | 稳定 |
800 | 8 | 好 | 好 | 稳定 |
800 | 16 | 好 | 好 | 稳定 |
2000 | 2 | 有点延迟 | 好 | 稳定 |
2000 | 4 | 延迟大 | 好 | 稳定 |
2000 | 8 | 延迟较大 | 好 | 稳定 |
2000 | 16 | 延迟很大 | 好 | 稳定 |
4000 | 2 | 延迟大 | 好 | 稳定 |
4000 | 4 | 延迟较大 | 好 | 稳定 |
4000 | 8 | 延迟很大 | 好 | 稳定 |
4000 | 16 | 很差 | 好 | 稳定 |
根据之前分析及上面测试结果,不难发现在局域网中,网络比较稳定,话音基本上不丢失。
当缓冲区较小时,必须增大缓冲块数以保障话音的连续性;当缓冲较大时,必须减少缓冲块数以保证话音实时性。音频在网络拥塞控制时,如果nR移动速度比nP快,则他们之间最大差距可能是8,若每块大小为4000B,则就有4000×8=32000B数据的延迟。
所以在实际应用中,可将缓冲大小和缓冲块数设置比较合适的值,例如:400,16;或800,8;也可以调整网络拥塞控制策略优化话音效果,例如可将nR和nP的差值幅度调小作为网络正常的情况。
10. 结束语
网络拥塞控制策略和双/多缓冲技术是实现实时连续话音通信的关键技术。在实际应用中建立两个UDP套接字:一个用于发送音频数据,一个用于接收音频数据,利用WaveX音频函数采集播放声音,可以很好的实现音频的实时通信。
附录A WaveX API
waveInGetNumDevs | 返回系统中存在的波形输入设备的数量 |
waveInAddBuffer | 向波形输入设备添加一个输入缓冲区 |
waveInGetDevCaps | 查询指定的波形输入设备以确定其性能 |
waveInGetErrorText | 检取由指定的错误代码标识的文本说明 |
waveInGetID | 获取指定的波形输入设备的标识符 |
waveInGetPosition | 检取指定波形输入设备的当前位置 |
waveInMessage | 发送一条消息给波形输入设备的驱动器 |
waveInOpen | 为录音而打开一个波形输入设备 |
waveInPrepareHeader | 为波形输入准备一个输入缓冲区 |
waveInStart | 启动在指定的波形输入设备的输入 |
waveInReset | 停止给定的波形输入设备的输入,且将当前位置清零 |
waveInStop | 停止在指定的波形输入设备上的输入 |
waveInUnprepareHeader | 清除由waveInPrepareHeader函数实现的准备 |
WaveInClose | 关闭指定的波形输入设置 |
waveOutBreakLoop | 中断给定的波形输出设备上一个循环,并允许播放驱动取列表中的下一个块 |
waveOutClose | 关闭指定的波形输出设备 |
waveOutGetDevCaps | 查询一个指定的波形输出设备以确定其性能 |
waveOutGetErrorText | 检取由指定的错误代码标识的文本说明 |
waveOutGetID | 检取指定的波形输出设备的标识符 |
waveOutGetNumDevs | 检取系统中存在的波形输出设备的数量 |
waveOutGetPitch | 查询一个波形输出设备的当前音调设置 |
waveOutGetPlaybackRate | 查询一个波形输出设备当前播放的速度 |
waveOutGetPosition | 检取指定波形输出设备的当前播放位置 |
waveOutGetVolume | 查询指定波形输出设备的当前音量设置 |
waveOutMessage | 发送一条消息给一个波形输出设备的驱动器 |
waveOutOpen | 为播放打开一个波形输出设备 |
waveOutPause | 暂停指定波形输出设备上的播放 |
waveOutPrepareHeader | 为播放准备一个波形缓冲区 |
waveOutRestart | 重新启动一个被暂停的波形输出设备 |
waveOutSetPitch | 设置一个波形输出设备的音调 |
waveOutSetPlaybackRate | 设置指定波形输出设备的速度 |
waveOutSetVolume | 设置指定的波形输出设备的音量 |
waveOutUnprepareHeader | 清除由waveOutPrepareHeader函数实现的准备 |
waveOutWrite | 向指定的波形输出设备发送一个数据块 |
- END -