[声明:本篇来源:http://www.cnblogs.com/stg609/archive/2008/11/19/1334544.html 作者:stg609]
现在时下的VOIP软件很多,比较有名的就是Skype,还有其它诸如UUcall、快门等等。它们提供的功能除了网络上的语音通话外,还可以与固定电话、手机等通话。在本篇中主要介绍利用C#实现语音通信的基本方法。但是目前只实现了网络上语音传输的基本功能,而且比较粗糙,没有采用什么算法来优化,所以大家千万不要期望过高。我写这篇的目的除了记录自己的经历之外,更希望有高手能给出改进的意见或算法。
开发平台:.NET Framework 2.0 ,VS 2005,Windows XP,DirectX SDK(June 2008) 。
开发语言:C#。
测试环境:Windows XP 、.net framework 2.0、普通局域网。
测试结果:在多台安装了windows XP系统且配置不同的电脑上测试,均能正常运行。可以进行语音对话,但是有明显的杂音,沿时低。
限于篇幅,在本文中会详细介绍本人认为比较关健的问题,其它部分只做大概介绍,为了便于大家理解,可以先阅读:
1.DirectX编程:[初级]C# 中利用 DirectSound 录音
2.C# Socket编程笔记
在本文中打算按照以下顺序介绍:
1.项目结果预览与说明
2.实现方法概要
3.语言采集
4.语音传输
5.语音播放
项目结果预览与说明
界面如下:
说明:界面很简单,只提供了一个选择或输入对方IP的功能,当选择合适局域网内IP之后,单击确定便激活了语音聊天的按钮。如果你想进行语音聊天就可以开始聊天了,聊天端口采用8000。本软件只适用于局域网内用户的聊天,另外因为没有增加用户认证的功能,所以只有在双方都启动了这款软件才能进行通信。如果只想在单机上测试,那只需要选择本机的IP便可。由于囧于技术水平,尝试N次之后,任不知如何才能正确地实现语音效果(如回声消除、降噪等)来保障音质,因此在单机测试会有回声干扰,嚣叫声比较严重,希望高手解囊。
实现方法概要
要想实现语音聊天,有几个步骤是必须的(就是我不说,相信你应该也能想得到一些):
a 语音采集:采集的作用就是从你的麦克风中获取数据,我采用DirectSound类来实现这个技术。参考:C# 中利用 DirectSound 录音
(b 语音编码:利用语音编码算法对采集到的话音进行压缩编码,进行编码的目的是为了减少网络带宽的压力。)
c 语音传输:将采集到的声音传输到网络上的其它主机,我采用Socket UDP方式来实现。参考:C# Socket编程笔记
(d 语音解码:如果所传输的语音进行过压缩编码,则必须对语音进行解码,否则无法得到原始语音数据。)
e 语音播放:当对方通过网络传输到本机时(,如果需要解码则先执行d),进行实时播放。
上面红色标记的步骤,可以省略。在本软件中,我并未采用这两个步骤,因为当我采用了这两个步骤后,发现语音时延异常的严重。我采用的编解码算法是G.729,利用的是g729.dll库文件,压缩效果不错,但是时延比较严重,可能是自己哪里没有设置好。如果有朋友使用过该算法,且时延低的,希望不吝赐教。
接下来,重点介绍语音采集、语音传输、语音播放的实现。
语音采集
由于所实现的方法与录音方法一致,因此不会着墨过多,如果你不能很好的理解,请先参考:C# 中利用 DirectSound 录音
与录音不同的是,录音我们需要建立一个WAVE文件来存储这些采集到的数据,而在语音聊天中,则不需要存储,当采集到一些数据后,就立刻发送出去,因此也不需要开辟很大的空间来存放PCM数据。
我们先来回顾下采集的基本步骤:
1. 设置PCM格式,设置相关的参数,如:采样频率、量化位数等。
2. 建立采集用的设备对象,建立采集用的缓冲区对象。
3. 设置缓冲区通知,设置通知被触发后的事件。通知是用于当缓冲区的读指针达到某预设位置时触发通知事件,提醒我们可以对某部分的数据进行传送了。
4. 开始采集声音。
5. 当通知被触发后,建立一个新的线程来处理数据传送的事件。(建立一个新的线程,就是为了防止采集过程被中断)。
///
/// 设置音频格式,如采样率等
///
///
private WaveFormat SetWaveFormat()
{
WaveFormat format = new WaveFormat();
format.FormatTag = WaveFormatTag.Pcm;//设置音频类型
format.SamplesPerSecond = 11025;//采样率(单位:赫兹)典型值:11025、22050、44100Hz
format.BitsPerSample = 16;//采样位数
format.Channels = 1;//声道
format.BlockAlign = (short)(format.Channels * (format.BitsPerSample / 8));//单位采样点的字节数
format.AverageBytesPerSecond = format.BlockAlign * format.SamplesPerSecond;
return format;
//按照以上采样规格,可知采样1秒钟的字节数为22050*2=44100B 约为 43K
}
///
/// 创建捕捉设备对象
///
///
private bool CreateCaputerDevice()
{
//首先要玫举可用的捕捉设备
CaptureDevicesCollection capturedev = new CaptureDevicesCollection();
Guid devguid;
if (capturedev.Count > 0)
{
devguid = capturedev[0].DriverGuid;
}
else
{
System.Windows.Forms.MessageBox.Show("当前没有可用于音频捕捉的设备", "系统提示");
return false;
}
//利用设备GUID来建立一个捕捉设备对象
capture = new Capture(devguid);
return true;
}
///
/// 创建捕捉缓冲区对象
///
private void CreateCaptureBuffer()
{
//想要创建一个捕捉缓冲区必须要两个参数:缓冲区信息(描述这个缓冲区中的格式等),缓冲设备。
WaveFormat mWavFormat = SetWaveFormat();
CaptureBufferDescription bufferdescription = new CaptureBufferDescription();
bufferdescription.Format = mWavFormat;//设置缓冲区要捕捉的数据格式
iNotifySize = mWavFormat.AverageBytesPerSecond / iNotifyNum;//1秒的数据量/设置的通知数得到的每个通知大小小于0.2s的数据量,话音延迟小于200ms为优质话音
iBufferSize = iNotifyNum * iNotifySize;
bufferdescription.BufferBytes = iBufferSize;
bufferdescription.ControlEffects = true;
bufferdescription.WaveMapped = true;
capturebuffer = new CaptureBuffer(bufferdescription, capture);//建立设备缓冲区对象
}
//设置通知
private void CreateNotification()
{
BufferPositionNotify[] bpn = new BufferPositionNotify[iNotifyNum];//设置缓冲区通知个数
//设置通知事件
notifyEvent = new AutoResetEvent(false);
notifyThread = new Thread(RecoData);//通知触发事件
notifyThread.IsBackground = true;
notifyThread.Start();
for (int i = 0; i < iNotifyNum; i++)
{
bpn[i].Offset = iNotifySize + i * iNotifySize - 1;//设置具体每个的位置
bpn[i].EventNotifyHandle = notifyEvent.Handle;
}
myNotify = new Notify(capturebuffer);
myNotify.SetNotificationPositions(bpn);
}
//线程中的事件
private void RecoData()
{
while (true)
{
// 等待缓冲区的通知消息
notifyEvent.WaitOne(Timeout.Infinite, true);
// 录制数据
RecordCapturedData(Client,epServer);
}
}
//真正转移数据的事件,其实就是把数据传送到网络上去。
private void RecordCapturedData(Socket Client,EndPoint epServer )
{
byte[] capturedata = null;
int readpos = 0, capturepos = 0, locksize = 0;
capturebuffer.GetCurrentPosition(out capturepos, out readpos);
locksize = readpos - iBufferOffset;//这个大小就是我们可以安全读取的大小
if (locksize == 0)
{
return;
}
if (locksize < 0)
{//因为我们是循环的使用缓冲区,所以有一种情况下为负:当文以载读指针回到第一个通知点,而Ibuffeoffset还在最后一个通知处
locksize += iBufferSize;
}
capturedata = (byte[])capturebuffer.Read(iBufferOffset, typeof(byte), LockFlag.FromWriteCursor, locksize);
//capturedata = g729.Encode(capturedata);//语音编码
try
{
Client.SendTo(capturedata, epServer);//传送语音
}
catch
{
throw new Exception();
}
iBufferOffset += capturedata.Length;
iBufferOffset %= iBufferSize;//取模是因为缓冲区是循环的。
}
上述代码可以很好的采集到声音数据,几乎与原始声音一致。如果你已经可以实现录音,那么以上对你来说应该并不陌生。
语音传输
这部分并不是很难,如果你熟悉socket编程,那么就可以PASS这一部分了,与以往传输不同的只是现在传输的是语音而已。如果你没接触过socket,那可以瞧瞧C# Socket编程笔记。
感觉这部分叫“语音传输”并不是很恰当,因为其实真正用于传输的语句只有一句。除了语音传输之外,我们还需要对网络进行监听,从而能捕获对方发送给自己的语音信息。但是,也不知道叫什么好,就估且这么叫着吧。在这一部分,我主要讲下大致流程。
1. 建立socket对象,在实例化这个对象的时候有一个参数是设置使用的协议,在本软件中,我采用的是UDP。
为什么要采用UDP?建立TCP能不能传送语音,答案肯定是能的。在本软件中,我考虑的主要是语音延时问题, 采用TCP在建立连接和维护连接中对时间和系统资源的开销较大,因此会有明显的时延发生,严重影响了实时性。另外,因为UDP是无连接的,这使得采用UDP可以支持日后功能上的扩展(如:组播)。
2. 绑定本机的IP和端口,因为一个主机可能会有不止一个IP地址,如回发地址:127.0.0.1 和局域网地址:192.168.#.#。为了增加可用性,我这里选择绑定到任何本机可用的IP地址(IPAddress.Any),而端口我们约定默认为8000。
3. 启动监听线程,来监听网络。我采用异步的方式,以便获得更好的系统响应度。
private Thread ListenThread;
private byte[] bytData;
///
/// 监听方法,用于监听远程发送到本机的信息
///
public void Listen()
{
ListenThread = new Thread(new ThreadStart(DoListen));
ListenThread.IsBackground = true;//设置为后台线程,这样当主线程结束后,该线程自动结束
ListenThread.Start();
}
private EndPoint epRemote;
///
/// 监听线程
///
private void DoListen()
{
bytData = new byte[intMaxDataSize];
epRemote = (EndPoint)(new IPEndPoint(IPAddress.Any, 0));
while (true)
{
if (LocalSocket.Poll(5000, SelectMode.SelectRead))
{//每5ms查询一下网络,如果有可读数据就接收
LocalSocket.BeginReceiveFrom(bytData, 0, bytData.Length, SocketFlags.None, ref epRemote, new AsyncCallback(ReceiveData), null);
}
}
}
///
/// 接收数据
///
///
private void ReceiveData(IAsyncResult iar)
{
int intRecv = 0;
try
{
intRecv = LocalSocket.EndReceiveFrom(iar, ref epRemote);
}
catch
{
throw new Exception();
}
if (intRecv > 0)
{
byte[] bytReceivedData = new byte[intRecv];
Buffer.BlockCopy(bytData, 0, bytReceivedData, 0, intRecv);
voicecapture1.GetVoiceData(intRecv, bytReceivedData);//调用声音模块中的GetVoiceData()从字节数组中获取声音并播放
//GetVoiceData()会在下一部分中提到
}
}
4. 数据的发送因为只有一句话,所以我直接放在上一部分的语音采集中了。
语音播放
最麻烦的就是这部分了,而且感觉现在的实现方法仍然需要改进才好。
当声音传输到本机后,该怎么样才能让这些数据经过音响设备放出声音来呢?因为声音播放是从缓冲区中获取声音数据的因此我们必须先将获取到的数据写入缓冲区,然后再调用相应的方法来播放。看起来似乎不复杂,可是实现起来远没有这么简单。
我遇到的问题:
大家可以看下语音采集部分,我是在每次通知后进行语音采集然后就将采集到的语音发送到网络上,如果运行正常的话,这一部分数据实际播放长度远小于1秒。也就是说对方每次接收到的语音长度为毫秒级。而且如果网络质量可以的话,那么连续两次接收到数据的时间间隔也是相当小的。这样就产生问题了,如果我在接收到第一次数据后,将它写入缓冲区,然后调用相应的播放方法,由于语音长度实际很短,因此几乎听不到什么效果,而且可能发生当第一次缓冲区中的数据还没播放完,就已经被第二次的数据覆盖,导致声音混乱。经测试,此种方法无法达到声音实时效果。期间我也曾修改过数据发送部分,希望当语音长度达到某一长度时在发送,可是问题依旧,看样子重要的是在接收端进行相应处理。
直接缓冲播放的方法不行,那就换~~
上网搜,可惜的是这方面的资料实在有限,C#的就更少了。参考一些文献,大家提到利用在缓冲区设置两个指针,一个播放指针,一个写指针(写指针用于表示当前从网络上接收到的数据从写指针所指示位置开始往下写,播放指针则表示当前所播放的数据末尾)。当播放指针达到某个位置时就播放某一部分数据,而不影响将被写入的缓冲区部分,这样就可以很好的解决数据覆盖的问题。除此之外,还要将缓冲区设置为循环缓冲区,也就是头尾相接,当到达尾部时,自己从部开始,此时将覆盖头部数据。
看了这些,你是不是感觉很眼熟?是不是和语音采集很类似?是的,我们在捕捉缓冲区中就是这样设置的,我们利用通知来设置触发事件。不同的是我们接收语音用的缓冲区并不是捕捉缓冲区,MS为捕捉单独设置了一个捕捉缓冲区。我们利用的是另一个缓冲区,辅助缓冲区(SecondaryBuffer)。后来发现该缓冲区也有类似的通知,这意味什么?我当时很兴奋,可是~~相当郁闷的是,我不管怎么设置通知,编译时都会报错,到外询求答案,均无果。在 MS 相关网站上咨询后,有一位叫jwatte的答案,让我又高兴又失望:
原话如下:
Notify is broken in DirectSound, has been for a long time, and probably will never be fixed.
The only way to know when you need to play the next piece of data is to check the play pointer each time through your main loop, and then lock the buffer and fill in whatever part has been played out.
Also, DirectSound is now in "maintenance" mode, and won't be further developed by Microsoft. Instead, for game applications, they recommend you use XAudio2 to play sound.
简单意思就是:Notify出问题已经很长时间了,而且MS可能永远都不会去修复这个问题。而且他也为播放声音提供了些建议,这些建议与上面所讲的基本一致。
至于这个答案是否正确,因为无从考证,就不再讨论了。如果哪位高手曾经实现过,希望赐教。
既然目前无法正常使用,就只能来手动写了。这个方法名就是:GetVoiceData()。
思路如下:
·利用MemoryStream来代表这个接收缓冲区。
·设置两个表示指针位置的字段:
private int intPosWrite = 0;//内存流中写指针位移
private int intPosPlay = 0;//内存流中播放指针位移
·当接收到数据后,则移动写指针,移动的长度为接收到的数据长度。
·利用一个字段表示通知大小:private int intNotifySize = 5000;
·当写指针的位置达到通知大小,则执行播放操作,然后移动播放指针到刚才的通知的位置。
·如果当前写指针的位移与将要写入到缓冲区的数据大小相加后超过缓冲容量的,则进行摩尔运算,实现循环的效果。
private int intPosWrite = 0;//内存流中写指针位移
private int intPosPlay = 0;//内存流中播放指针位移
private int intNotifySize = 5000;//设置通知大小
///
/// 从字节数组中获取音频数据,并进行播放
///
/// 字节数组长度
/// 包含音频数据的字节数组
public void GetVoiceData(int intRecv, byte[] bytRecv)
{
//intPosWrite指示最新的数据写好后的末尾。intPosPlay指示本次播放开始的位置。
if (intPosWrite + intRecv <= memstream.Capacity)
{//如果当前写指针所在的位移+将要写入到缓冲区的长度小于缓冲区总大小
if ((intPosWrite - intPosPlay >= 0 && intPosWrite - intPosPlay < intNotifySize) || (intPosWrite - intPosPlay < 0 && intPosWrite - intPosPlay + memstream.Capacity < intNotifySize))
{
memstream.Write(bytRecv, 0, intRecv);
intPosWrite += intRecv;
}
else if (intPosWrite - intPosPlay >= 0)
{//先存储一定量的数据,当达到一定数据量时就播放声音。
buffDiscript.BufferBytes = intPosWrite - intPosPlay;//缓冲区大小为播放指针到写指针之间的距离。
SecondaryBuffer sec = new SecondaryBuffer(buffDiscript, PlayDev);//建立一个合适的缓冲区用于播放这段数据。
memstream.Position = intPosPlay;//先将memstream的指针定位到这一次播放开始的位置
sec.Write(0, memstream, intPosWrite - intPosPlay, LockFlag.FromWriteCursor);
sec.Play(0, BufferPlayFlags.Default);
memstream.Position = intPosWrite;//写完后重新将memstream的指针定位到将要写下去的位置。
intPosPlay = intPosWrite;
}
else if (intPosWrite - intPosPlay < 0)
{
buffDiscript.BufferBytes = intPosWrite - intPosPlay + memstream.Capacity;//缓冲区大小为播放指针到写指针之间的距离。
SecondaryBuffer sec = new SecondaryBuffer(buffDiscript, PlayDev);//建立一个合适的缓冲区用于播放这段数据。
memstream.Position = intPosPlay;
sec.Write(0, memstream, memstream.Capacity - intPosPlay, LockFlag.FromWriteCursor);
memstream.Position = 0;
sec.Write(memstream.Capacity - intPosPlay, memstream, intPosWrite, LockFlag.FromWriteCursor);
sec.Play(0, BufferPlayFlags.Default);
memstream.Position = intPosWrite;
intPosPlay = intPosWrite;
}
}
else
{//当数据将要大于memstream可容纳的大小时
int irest = memstream.Capacity - intPosWrite;//memstream中剩下的可容纳的字节数。
memstream.Write(bytRecv, 0, irest);//先写完这个内存流。
memstream.Position = 0;//然后让新的数据从memstream的0位置开始记录
memstream.Write(bytRecv, irest, intRecv - irest);//覆盖旧的数据
intPosWrite = intRecv - irest;//更新写指针位置。写指针指示下一个开始写入的位置而不是上一次结束的位置,因此不用减一
}
}
这样,基本上就可以实现语音聊天了。可是这样的效果还只能是初步的,而且由于回声的原因,相当影响音质,还可能产生嚣叫,为了解决这个问题,我本打算采用MS提供的AEC算法,可是由于不知道如何实现,一直无法得到效果,因此这也是比较遗憾的地方。
可执行文件(注:要在安装了 .net framework 2.0 的平台上运行):MatureVoiceEXE.rar
源文件:MatureVoice.rar
出处:http://stg609.cnblogs.com/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。