天天看點

DirectX程式設計:C#中利用Socket實作網絡語音通信[初級版本]

      現在時下的VOIP軟體很多,比較有名的就是Skype,還有其它諸如UUcall、 快門等等。它們提供的功能除了網絡上的語音通話外,還可以與固定電話、手機等通話。在本篇中主要介紹利用C#實作語音通信的基本方法。但是目前隻實作了網 絡上語音傳輸的基本功能,而且比較粗糙,沒有采用什麼算法來優化,是以大家千萬不要期望過高。我寫這篇的目的除了記錄自己的經曆之外,更希望有高手能給出 改進的意見或算法。

     開發語言:C#。

     測試環境:Windows XP 、.net framework 2.0、普通區域網路。

     測試結果:在多台安裝了windows XP系統且配置不同的電腦上測試,均能正常運作。可以進行語音對話,但是有明顯的雜音,沿時低。

     限于篇幅,在本文中會詳細介紹本人認為比較關健的問題,其它部分隻做大概介紹,為了便于大家了解,可以先閱讀:

     在本文中打算按照以下順序介紹:

     1.項目結果預覽與說明

     2.實作方法概要

     3.語言采集

     4.語音傳輸

     5.語音播放

     項目結果預覽與說明

      界面如下:

DirectX程式設計:C#中利用Socket實作網絡語音通信[初級版本]

      說明:界面很簡單,隻提供了一個選擇或輸入對方IP的功能,當選擇合适區域網路内IP之後,單擊确定便激活了語音聊天的按鈕。如果你想進行語音聊天就可以開 始聊天了,聊天端口采用8000。本軟體隻适用于區域網路内使用者的聊天,另外因為沒有增加使用者認證的功能,是以隻有在雙方都啟動了這款軟體才能進行通信。如 果隻想在單機上測試,那隻需要選擇本機的IP便可。由于囧于技術水準,嘗試N次之後,任不知如何才能正确地實作語音效果(如回聲消除、降噪等)來保障音 質,是以在單機測試會有回聲幹擾,嚣叫聲比較嚴重,希望高手解囊。

      實作方法概要

      要想實作語音聊天,有幾個步驟是必須的(就是我不說,相信你應該也能想得到一些):

   (b 語音編碼:利用語音編碼算法對采集到的話音進行壓縮編碼,進行編碼的目的是為了減少網絡帶寬的壓力。)

   (d 語音解碼:如果所傳輸的語音進行過壓縮編碼,則必須對語音進行解碼,否則無法得到原始語音資料。)

      e 語音播放:當對方通過網絡傳輸到本機時(,如果需要解碼則先執行d),進行實時播放。

      上面紅色标記的步驟,可以省略。在本軟體中,我并未采用這兩個步驟,因為當我采用了這兩個步驟後,發現語音時延異常的嚴重。我采用的編解碼算法是 G.729,利用的是g729.dll庫檔案,壓縮效果不錯,但是時延比較嚴重,可能是自己哪裡沒有設定好。如果有朋友使用過該算法,且時延低的,希望不 吝賜教。

      接下來,重點介紹語音采集、語音傳輸、語音播放的實作。

      語音采集

      與錄音不同的是,錄音我們需要建立一個WAVE檔案來存儲這些采集到的資料,而在語音聊天中,則不需要存儲,當采集到一些資料後,就立刻發送出去,是以也不需要開辟很大的空間來存放PCM資料。

      我們先來回顧下采集的基本步驟:

      1. 設定PCM格式,設定相關的參數,如:采樣頻率、量化位數等。

      2. 建立采集用的裝置對象,建立采集用的緩沖區對象。

      3. 設定緩沖區通知,設定通知被觸發後的事件。通知是用于當緩沖區的讀指針達到某預設位置時觸發通知事件,提醒我們可以對某部分的資料進行傳送了。

      4. 開始采集聲音。

      5. 當通知被觸發後,建立一個新的線程來處理資料傳送的事件。(建立一個新的線程,就是為了防止采集過程被中斷)。

DirectX程式設計:C#中利用Socket實作網絡語音通信[初級版本]
DirectX程式設計:C#中利用Socket實作網絡語音通信[初級版本]

主要代碼

        /// <summary>

        /// 設定音頻格式,如采樣率等

        /// </summary>

        /// <returns>設定完成後的格式</returns>

        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

        }

        /// 建立捕捉裝置對象

        /// <returns>如果建立成功傳回true</returns>

        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;//取模是因為緩沖區是循環的。

      上述代碼可以很好的采集到聲音資料,幾乎與原始聲音一緻。如果你已經可以實作錄音,那麼以上對你來說應該并不陌生。

      語音傳輸

      感覺這部分叫“語音傳輸”并不是很恰當,因為其實真正用于傳輸的語句隻有一句。除了語音傳輸之外,我們還需要對網絡進行監聽,進而能捕獲對方發送給自己的語音資訊。但是,也不知道叫什麼好,就估且這麼叫着吧。在這一部分,我主要講下大緻流程。

      1. 建立socket對象,在執行個體化這個對象的時候有一個參數是設定使用的協定,在本軟體中,我采用的是UDP。

      為什麼要采用UDP?建立TCP能不能傳送語音,答案肯定是能的。在本軟體中,我考慮的主要是語音延時問題, 采用TCP在建立連接配接和維護連接配接中對時間和 系統資源的開銷較大,是以會有明顯的時延發生,嚴重影響了實時性。另外,因為UDP是無連接配接的,這使得采用UDP可以支援日後功能上的擴充(如:多點傳播)。

      2. 綁定本機的IP和端口,因為一個主機可能會有不止一個IP位址,如回發位址:127.0.0.1 和區域網路位址:192.168.#.#。為了增加可用性,我這裡選擇綁定到任何本機可用的IP位址(IPAddress.Any),而端口我們約定預設為 8000。

      3. 啟動監聽線程,來監聽網絡。我采用異步的方式,以便獲得更好的系統響應度。

DirectX程式設計:C#中利用Socket實作網絡語音通信[初級版本]
DirectX程式設計:C#中利用Socket實作網絡語音通信[初級版本]

        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));

                if (LocalSocket.Poll(5000, SelectMode.SelectRead))

                {//每5ms查詢一下網絡,如果有可讀資料就接收

                    LocalSocket.BeginReceiveFrom(bytData, 0, bytData.Length, SocketFlags.None, ref epRemote, new AsyncCallback(ReceiveData), null);

                }

        /// 接收資料

        /// <param name="iar"></param>

        private void ReceiveData(IAsyncResult iar)

            int intRecv = 0;

                intRecv = LocalSocket.EndReceiveFrom(iar, ref epRemote);

            if (intRecv > 0)

                byte[] bytReceivedData = new byte[intRecv];

                Buffer.BlockCopy(bytData, 0, bytReceivedData, 0, intRecv);

                voicecapture1.GetVoiceData(intRecv, bytReceivedData);//調用聲音子產品中的GetVoiceData()從位元組數組中擷取聲音并播放

                  //GetVoiceData()會在下一部分中提到

      4. 資料的發送因為隻有一句話,是以我直接放在上一部分的語音采集中了。

Client.SendTo(capturedata, epServer);//傳送語音

      語音播放

      最麻煩的就是這部分了,而且感覺現在的實作方法仍然需要改進才好。

      當聲音傳輸到本機後,該怎麼樣才能讓這些資料經過音響裝置放出聲音來呢?因為聲音播放是從緩沖區中擷取聲音資料的是以我們必須先将擷取到的資料寫入緩沖區,然後再調用相應的方法來播放。看起來似乎不複雜,可是實作起來遠沒有這麼簡單。

      我遇到的問題:

      大家可以看下語音采集部分,我是在每次通知後進行語音采集然後就将采集到的語音發送到網絡上,如果運作正常的話,這一部分資料實際播放長度遠 小于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;

      ·當寫指針的位置達到通知大小,則執行播放操作,然後移動播放指針到剛才的通知的位置。

      ·如果目前寫指針的位移與将要寫入到緩沖區的資料大小相加後超過緩沖容量的,則進行摩爾運算,實作循環的效果。

DirectX程式設計:C#中利用Socket實作網絡語音通信[初級版本]
DirectX程式設計:C#中利用Socket實作網絡語音通信[初級版本]

GetVoiceData()

        private int intPosWrite = 0;//記憶體流中寫指針位移

        private int intPosPlay = 0;//記憶體流中播放指針位移

        private int intNotifySize = 5000;//設定通知大小

        /// 從位元組數組中擷取音頻資料,并進行播放

        /// <param name="intRecv">位元組數組長度</param>

        /// <param name="bytRecv">包含音頻資料的位元組數組</param>

        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;//緩沖區大小為播放指針到寫指針之間的距離。

                    memstream.Position = intPosPlay;

                    sec.Write(0, memstream, memstream.Capacity - intPosPlay, LockFlag.FromWriteCursor);

                    memstream.Position = 0;

                    sec.Write(memstream.Capacity - intPosPlay, memstream, intPosWrite, LockFlag.FromWriteCursor);

                    memstream.Position = intPosWrite;

            {//當資料将要大于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算法,可是由于不知道如何實作,一直無法得到效果,是以這也是比較遺憾的地方。

本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接,否則保留追究法律責任的權利。

繼續閱讀