基於Orangpi Zero和Linux ALSA實現WIFI無線音箱(二)
作品已經完成,先上源碼:
https://files.cnblogs.com/files/qzrzq1/WIFISpeaker.zip
全文包含三篇,這是第二篇,主要講述發送端程序的原理和過程。
第一篇:基於Orangpi Zero和Linux ALSA實現WIFI無線音箱(一)
第三篇:基於Orangpi Zero和Linux ALSA實現WIFI無線音箱(三)
以下是正文:
發送端程序基於MFC的對話框類實現,開發環境Visual Studio 2012,主要實現了5個功能,下面逐個講述:
1、軟件啟動檢查互斥體,防止程序重復啟動。
2、讀取上一次啟動的配置文件,初始化socket、獲取本機ip地址。
3、讀取用戶輸入的接收端IP地址,利用Core Audio APIs初始化loopback(環回錄音)模式,啟動錄音子線程。
4、在子線程不斷讀取音頻緩沖區數據,每0.1s將錄制的數據打包以PCM格式,通過socket發送到接收端。
5、最小化到系統托盤
一、檢查互斥體
創建互斥體是防止應用程序重復啟動最常用的方式,本作品使用Core Audio APIs讀取聲卡音頻數據,只能實例化一次。這是因為,這個作品完成後,作者在使用的過程中,發送端軟件在運行一段時間後,總是不定期莫名其妙地出現“appcrash”錯誤,然後程序莫名崩潰,後來發現是因為作者之前使用過一個叫“wifiaudio”的程序,這個程序也是一樣利用Core Audio APIs實現聲卡的環回錄音,而且它老是開機自啟動,這樣當我也運行這個作品的時候,兩個程序就出現沖突,導致本作品運行不穩定,在解決了這個問題之後,作者也在作品中增加檢查互斥體的功能,防止程序重復啟動。
以下是在應用程序實例化時增加的代碼。
//創建互斥體,防止應用程序重復啟動,by Hecan HANDLE hMutex = ::CreateMutex(NULL, FALSE, "WifiSpeaker by Hecan"); DWORD dwRet = ::GetLastError(); if (hMutex) { if (ERROR_ALREADY_EXISTS == dwRet) { AfxMessageBox("應用程序已經運行,請關閉後重試!!!"); CloseHandle(hMutex);// should be closed return FALSE; } } else AfxMessageBox("創建互斥體錯誤,請檢查源代碼WiFiSpeaker.cpp");
最後建議在dlg.DoModal()返回後增加關閉句柄的代碼,雖然這工作在軟件退出時系統會自動完成,但不建議由系統來做。
// 關閉互斥體句柄 CloseHandle(hMutex);
二、讀取上一次啟動的配置文件,初始化socket
上一次啟動的配置文件默認保存在可執行文件當前的目錄下,後綴名為bin,這個文件只有一個作用,就是保存用戶上一次退出時設定的接收端IP地址,減少用戶每次打開程序都要設置IP的麻煩,這個文件固定16個字節,實際就是m_ClientAddr這個成員變量以2進制形式保存在bin文件中,m_ClientAddr成員變量的類型為SOCKADDR_IN結構體。
代碼中註意一下:
1、發送端配置的端口為12320,接收端端為12321,這個是在程序中固化的,沒有提供給用戶做修改,這個值只能在源代碼中修改後重新編譯。修改後,接收端對應的本機端口也要同步修改。
2、初始化中使用ioctlsocket函數把socket配置為非阻塞模式,這樣後面調用sendto函數後,函數會立即返回。因為是UDP協議,數據發送後不需要關心接收端有沒有收到,直接返回即可,提高程序的執行效率。
3、BuffDuration_millisec是成員變量,表示初始化音頻客戶端請求的數據緩沖區大小,以毫秒為單位。後面會講到。
初始化代碼如下:
BOOL CWiFiSpeakerDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 設置此對話框的圖標。當應用程序主窗口不是對話框時,框架將自動 // 執行此操作 SetIcon(m_hIcon, TRUE); // 設置大圖標 SetIcon(m_hIcon, FALSE); // 設置小圖標 // TODO: 在此添加額外的初始化代碼 /*--------------------------------------------------------------------------------------------------------*/ //讀取初始化文件,如果沒有,則按照默認192.168.1.100的ip地址初始化客戶端ip,客戶端口設為12321 CFile iniFile; //iniFile.Open("./WiFiSpeaker.bin",CFile::modeReadWrite |CFile::modeCreate|CFile::modeNoTruncate); volatile BOOL resul = iniFile.Open("./WiFiSpeaker.bin",CFile::modeReadWrite |CFile::modeCreate|CFile::modeNoTruncate); if(iniFile.GetLength() == sizeof(m_ClientAddr)) iniFile.Read(&m_ClientAddr,sizeof(m_ClientAddr)); else { m_ClientAddr.sin_family = AF_INET; m_ClientAddr.sin_port = htons(12321); m_ClientAddr.sin_addr.S_un.S_addr =inet_addr("192.168.1.100"); } iniFile.Close(); //初始化服務器IP地址,獲取本機IP地址,服務器端口設置設為12320 m_ServerAddr.sin_family = AF_INET; m_ServerAddr.sin_port = htons(12320); m_ServerAddr.sin_addr = GetLocalIPAddr(); //把IP地址轉為字符串並顯示在編輯框中 char a[15]; sprintf_s(a,"%d.%d.%d.%d",m_ServerAddr.sin_addr.S_un.S_un_b.s_b1,m_ServerAddr.sin_addr.S_un.S_un_b.s_b2,m_ServerAddr.sin_addr.S_un.S_un_b.s_b3,m_ServerAddr.sin_addr.S_un.S_un_b.s_b4); this->SetDlgItemText(IDC_EDIT1,a);//服務器(本機)ip sprintf_s(a,"%d.%d.%d.%d",m_ClientAddr.sin_addr.S_un.S_un_b.s_b1,m_ClientAddr.sin_addr.S_un.S_un_b.s_b2,m_ClientAddr.sin_addr.S_un.S_un_b.s_b3,m_ClientAddr.sin_addr.S_un.S_un_b.s_b4); this->SetDlgItemText(IDC_EDIT2,a);//客戶端ip this->GetDlgItem(IDC_BUTTON2)->EnableWindow(FALSE);//停止按鈕禁用 //初始化socket並綁定到主機地址,UDP模式 m_socket = socket(AF_INET,SOCK_DGRAM,0); bind(m_socket,(SOCKADDR*)&m_ServerAddr,sizeof(SOCKADDR));//綁定套接字 u_long mode = 1; ioctlsocket(m_socket,FIONBIO,&mode);//設置為非阻塞模式(sendto函數立即返回) /*---------------------------------------------------------------------------------------------------------*/ //設置0.1s時長的音頻緩沖區 BuffDuration_millisec = 100; //初始化成員變量 pAudioClient = NULL; pCaptureClient = NULL; pwfx =NULL; /*---------------------------------------------------------------------------------------------------------*/ //對話框初始化在屏幕右下角位置 CRect dlg_windows,sysWorkArea; SystemParametersInfo(SPI_GETWORKAREA,0,&sysWorkArea,0); GetWindowRect(&dlg_windows); SetWindowPos(NULL,sysWorkArea.right-dlg_windows.right, sysWorkArea.bottom-dlg_windows.bottom, 0, 0, SWP_NOSIZE | SWP_NOZORDER); return TRUE; // 除非將焦點設置到控件,否則返回 TRUE }
三、啟動按鈕——讀取用戶輸入的接收端IP地址,初始化loopback(環回錄音)模式,啟動錄音子線程
點擊啟動按鈕後,首先讀取用戶輸入的接收端IP地址,並存放在m_ClientAddr成員變量中。
初始化音頻客戶端為loopback模式,這部分代碼是參考msdn上的:https://msdn.microsoft.com/en-us/library/windows/desktop/dd316551(v=vs.85).aspx,主要有兩個地方要註意:
1、IMMDeviceEnumerator::GetDefaultAudioEndpoint函數的第一個參數必須為eRender。
2、IAudioClient::Initialize函數第二個參數需配置為AUDCLNT_STREAMFLAGS_LOOPBACK。
下面主要講述IAudioClient::Initialize函數,這個函數的聲明如下:
HRESULT Initialize( [in] AUDCLNT_SHAREMODE ShareMode, [in] DWORD StreamFlags, [in] REFERENCE_TIME hnsBufferDuration, [in] REFERENCE_TIME hnsPeriodicity, [in] const WAVEFORMATEX *pFormat, [in] LPCGUID AudioSessionGuid );
全部都是輸入參數,
ShareMode:共享模式獨占還是共享,AUDCLNT_SHAREMODE_EXCLUSIVE或者AUDCLNT_SHAREMODE_SHARED,一般設置為AUDCLNT_SHAREMODE_SHARED。涉及知識產權問題時才使用獨占模式。
StreamFlags:流標誌,本程序必須設為環回錄音模式,AUDCLNT_STREAMFLAGS_LOOPBACK。
pFormat:指定格式描述符,在程序中,我們先調用IAudioClient::GetMixFormat函數,獲取聲卡默認的錄音格式,再做適當修改,例如把采樣位深度修改由32位調整為16位,有助於減少錄制的音頻數據量。
hnsBufferDuration:申請的buff持續時間,以100ns為單位,這個參數很重要,它指定了我們存放錄音數據緩沖區的大小,它是以時間為單位的。舉個例子,如果pFormat指定的音頻格式為48kHz、雙通道、16位深、無壓縮的音頻數據,那1s的數據量是48000×2×2=192000字節。如果把這個參數指定為1s,那麽函數就會給程序分配192k字節的空間。在本程序中,設定每0.05s發送一次音頻數據,所以把這個參數設定為0.1s,即兩倍大小的緩沖區。
hnsPeriodicity、AudioSessionGuid:未使用,置為空即可。
調用該函數初始化音頻客戶端之後,必須使用IAudioClient::GetBufferSize獲取系統分配給程序的緩沖區大小:
HRESULT GetBufferSize( [out] UINT32 *pNumBufferFrames );
這個函數只有一個參數,指向UINT32類型變量的指針,這個變量用來存放系統給程序分配的緩沖區大小,以幀為單位。這裏解釋一下幀的含義,采樣一次即為一幀。2通道、32位深的音頻數據,一幀就有2×4=8個字節。看回上面的例子,48kHz、2通道、16位深的音頻數據,調用IAudioClient::Initialize函數申請0.1s的緩沖區,正常情況下,IAudioClient::GetBufferSize函數會返回4800,表示系統分配了4800幀、19200字節的緩沖區。
申請內存後,就可以調用AfxBeginThread函數啟動錄音及發送音頻數據子線程。以下為點擊啟動按鈕的處理代碼:
void CWiFiSpeakerDlg::OnBnClickedButton1() { // TODO: 在此添加控件通知處理程序代碼 //讀取設定的客戶端IP地址並存放到m_ClientAddr成員變量中 CString strIP; this->GetDlgItemText(IDC_EDIT2,strIP); m_ClientAddr.sin_addr.S_un.S_addr = inet_addr(strIP.GetBuffer(strIP.GetLength())); //檢測輸入的IP地址是否有誤 if(m_ClientAddr.sin_addr.S_un.S_addr == 0xffffffff) { AfxMessageBox("客戶端IP地址輸入有誤!!!"); return; } /*----------------------------------------------------------------------------------*/ //以下為實現系統錄音的代碼,大部分都是參考MSDN的例程 //捕獲(錄音)例程:https://msdn.microsoft.com/en-us/library/windows/desktop/dd370800(v=vs.85).aspx //環回錄音()系統錄音例程:https://msdn.microsoft.com/en-us/library/windows/desktop/dd316551(v=vs.85).aspx HRESULT hr; IMMDeviceEnumerator *pEnumerator = NULL; IMMDevice *pDevice = NULL; //指定初始化函數分配100ms的緩沖區,音頻設備的初始化函數只接受時間參數來分配內存空間,不能直接指定要多少字節 //例如44100Hz的音頻,0.1s就有4410幀數據(1幀就是一次采樣的數據量),如果是2通道,16位的話,那1幀數據就是4個字節,0.1s共17640字節 REFERENCE_TIME hnsRequestedDuration = BuffDuration_millisec*REFTIMES_PER_MILLISEC; //系統分配給我們的緩沖區,和上面的參數有關,以幀為單位,一般情況下我們申請的多長時間,按照采樣率就給我們分配多少幀的音頻緩沖區 UINT32 bufferFrameCount; //臨時的字符串變量 CString tempstr; //獲取設備枚舉器 hr = CoCreateInstance( CLSID_MMDeviceEnumerator, NULL, CLSCTX_ALL, IID_IMMDeviceEnumerator, (void**)&pEnumerator); //獲取默認音頻設備,註意,後面要初始化環回錄音模式,這裏必須是eRender參數,不能使用eCapture hr = pEnumerator->GetDefaultAudioEndpoint(eRender, eConsole, &pDevice ); //激活音頻客戶端 hr = pDevice->Activate( IID_IAudioClient, CLSCTX_ALL, NULL, (void**)&pAudioClient); SAFE_RELEASE(pEnumerator);//pEnumerator已使用完,釋放掉 SAFE_RELEASE(pDevice); if (FAILED(hr)) {this->SetDlgItemText(IDC_EDIT3,"初始化設備失敗code:1!");return;} //錯誤退出 //獲取默認的音頻格式 hr = pAudioClient->GetMixFormat(&pwfx); //調整為16位,PCM格式 AdjustFormatTo16Bits(pwfx); //音頻客戶端初始化,共享模式、換回錄音模式、申請0.1s的緩沖區 hr = pAudioClient->Initialize( AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_LOOPBACK, hnsRequestedDuration, 0, pwfx, NULL); if (FAILED(hr)) {this->SetDlgItemText(IDC_EDIT3,"初始化設備失敗code:2!");ErrorProcess();return;} //錯誤處理 //查看系統實際給我們分配多少的緩沖區 hr = pAudioClient->GetBufferSize(&bufferFrameCount); tempstr.Format("目標ip:%s\r\n%d采樣率%d通道%d位深\r\n實際系統分配緩沖區%d幀\r\n",strIP,pwfx->nSamplesPerSec,pwfx->nChannels,pwfx->wBitsPerSample,bufferFrameCount); this->SetDlgItemText(IDC_EDIT3,tempstr); //以下直接啟動錄音線程,因為pAudioClient->GetService和release()必須在同一個線程使用,所以只能在新線程裏獲取服務和啟動錄音。 //啟動錄音處理線程,所有的音頻數據的讀取、打包、發送都在這個線程完成 AfxBeginThread(RecordAndSendAudioStreamThread,this); bThreadisRunning = TRUE; /*----------------------------------------------------------------------------------------*/ this->GetDlgItem(IDC_EDIT2)->EnableWindow(FALSE);//編輯框只讀。 this->GetDlgItem(IDC_BUTTON1)->EnableWindow(FALSE);//開始按鈕禁用 this->GetDlgItem(IDC_BUTTON2)->EnableWindow(TRUE);//停止按鈕恢復 return; }
四、錄音及發送音頻數據子線程
子線程的工作就是啟動錄音,然後在循環中不斷讀取之前設置的音頻緩沖區,再通過socket發送出去。這裏有4點需要註意的:
1、用來存放音頻數據的緩沖區,作者在程序中是定義了一個long型的全局數組,有5000個數據大小。這個數組非常大,不能在子線程裏面定義這個數組,因為系統為子線程分配的堆棧空間有限,所以如果在子線程裏定義這麽大的數組,會導致軟件運行崩潰。
2、設定每0.05s發送一次音頻數據,但是0.05s的音頻數據無法一次全部讀出來,只能通過while循環,重復讀取系統緩沖區,直至全部讀出來為止。實際在測試中,可能由於線程調度導致延遲的關系,每0.05s的數據量有時會多一點,有時會少一點,所以之前初始化申請的緩沖區是按照0.05s的兩倍來申請的,防止數據溢出被覆蓋。
3、雙通道、16位深的音頻數據,一幀數據是4個字節,所以程序中以long型數據代表一幀數據,這樣在後續調用mencopy函數時就不用考慮字節對齊的問題了,相對比較方便。
4、數據包的格式問題,作者人為地設定數據包的前40個字節為數據格式描述,實際就是把pwfx這個變量的內容,作為包頭附到數據包中。這樣,在接收端就可以根據數據包的包頭獲取數據的分辨率、位深等信息了。
//啟動錄音處理線程,所有的音頻數據的讀取、打包、發送都在這個線程完成 UINT RecordAndSendAudioStreamThread(LPVOID pParam ) { CWiFiSpeakerDlg* dlg=(CWiFiSpeakerDlg*) pParam; HRESULT hr; //緩沖區的下一個數據包的長度,以幀為單位 UINT32 packetLength = 0; //緩沖區一次可以讀取的幀數量,這個參數和上面那個的數值是一樣的 //至於為什麽要設兩個,是因為使用的情況不一樣 //上面那個是以函數返回值的形式返回,這個是以形參的形式跟緩沖區起始地址一起返回的 UINT32 numFramesAvailable = 0; //標誌位,指示靜音什麽的,這裏不用 DWORD flags; //這個是數據緩沖區,傳遞給函數的指針變量 BYTE *pData; //計數器,記錄讀了多少數據幀數據 UINT32 Counter=0; //把音頻格式結構體復制到DataToSend中,占40個字節,真正的音頻數據從第41個字節開始 if(dlg->pwfx->wFormatTag == WAVE_FORMAT_EXTENSIBLE) memcpy(DataToSend,dlg->pwfx,sizeof(WAVEFORMATEXTENSIBLE)); else memcpy(DataToSend,dlg->pwfx,sizeof(WAVEFORMATEX)); //初始化定時器 LARGE_INTEGER FirstTime; HANDLE hTimerWakeUp = CreateWaitableTimer(NULL, FALSE, NULL); FirstTime.QuadPart = -dlg->BuffDuration_millisec * REFTIMES_PER_MILLISEC/2; //獲取音頻捕獲(錄音)客戶端 hr = dlg->pAudioClient->GetService( IID_IAudioCaptureClient, (void**)(&(dlg->pCaptureClient))); //啟動捕獲(錄音) hr = dlg->pAudioClient->Start(); if (FAILED(hr)) {dlg->SetDlgItemText(IDC_EDIT3,"初始化設備失敗code:3!");dlg->ErrorProcess();return 0;}//錯誤處理 //配置定時器,第一次信號定時0.05s,時間間隔0.05s,即每隔0.05把數據讀出來並發送 SetWaitableTimer(hTimerWakeUp,&FirstTime,(dlg->BuffDuration_millisec *5) /10,NULL, NULL, FALSE); //輸出重定向到txt文件的方法,在命令行啟動就可以看到調試信息,請參考https://blog.csdn.net/benkaoya/article/details/5935626 //printf("/-------------------------------------------------------------------------------/\n"); //主循環共有兩層,這是因為數據緩沖區共有兩個, //一個是音頻客戶端內部硬件的緩沖區(比較小,簡稱小buff,即下面pData指針),另一個是我們之前在初始化客戶端申請的緩沖區(比較大,簡稱大buff) //小buff我在自己計算機上測試48kHz的情況下,每次只能讀到480幀,可是我申請的大buff有0.1s,能裝4800幀 //所以需要多一層循環,把0.05s的數據以每次480的數量全部讀出來後,再發送出去。 //為什麽不直接把每次480的小buff直接發出去,而多弄一個大Buff?因為這樣的話會發送太頻繁,會造成網絡資源浪費 while (bThreadisRunning == TRUE) { Counter =sizeof(WAVEFORMATEXTENSIBLE)>>2; //計數器從置,從第41個字節開始寫音頻數據 //線程休眠,一直錄音,這裏設置的時間要比BuffDuration_millisec短,因為後面復制數據也是需要時間的 //官方給的例程是大buff時間的一半。 //Sleep((dlg->BuffDuration_millisec * 5) / 10); WaitForSingleObject(hTimerWakeUp,INFINITE); hr = dlg->pCaptureClient->GetNextPacketSize(&packetLength); //獲取包長度,以幀為單位,這裏獲取的是小buff的數據包長度 //輸出重定向到txt文件的方法,在命令行啟動就可以看到調試信息,請參考https://blog.csdn.net/benkaoya/article/details/5935626 //printf("\nCounter:numFA: "); while (packetLength != 0) { //獲取小buff的地址,同時獲取幀數量,這個幀數量和上面的包長度數值是一樣的 hr = dlg->pCaptureClient->GetBuffer(&pData,&numFramesAvailable,&flags, NULL, NULL); //輸出重定向到文件的方法,可以看到調試信息,請參考https://blog.csdn.net/benkaoya/article/details/5935626 //printf("%04d:%d; ",Counter,numFramesAvailable); //保存音頻數據 memcpy(&(DataToSend[Counter]),pData,numFramesAvailable*dlg->pwfx->nBlockAlign); //計數總共讀了多少幀 Counter += numFramesAvailable; //釋放小buff,並讀取下一個數據包長度 hr = dlg->pCaptureClient->ReleaseBuffer(numFramesAvailable); hr = dlg->pCaptureClient->GetNextPacketSize(&packetLength); } //這裏跳出循環,如果是48kHz采樣率的話,此時的Counter就應該為0.05s的幀數量,即2400幀 //因為復制數據、發送數據都是需要時間的,實際不一定每次都剛好是2400幀,可能會多一點點或者少一點點 //如果有數據,就立即socket發去客戶端 if(Counter > (sizeof(WAVEFORMATEXTENSIBLE)>>2)) sendto(dlg->m_socket,(char*)DataToSend,Counter<<2,0,(SOCKADDR *)(&(dlg->m_ClientAddr)),sizeof(SOCKADDR)); } //停止環回錄音 hr = dlg->pAudioClient->Stop(); CoTaskMemFree(dlg->pwfx); SAFE_RELEASE(dlg->pAudioClient) SAFE_RELEASE(dlg->pCaptureClient) return 0; }
五、最小化到系統托盤
這一塊內容就不說了,作者也是直接參考別人的代碼稍作修改實現的,可以參考:https://www.cnblogs.com/suthui/p/3492962.html
六、寫在最後
本作品發送的音頻數據都是未經壓縮的PCM原始數據,這種方法的好處就是發送端接收端沒有壓縮和解碼的過程,效率高,實時性好。缺點就是傳輸的數據量大,占用網絡帶寬,以作者的48kHz、2通道、16位深的音頻數據為例,網絡帶寬占用195KB/s。以下是發送端運行截圖及windows資源管理器網絡速度截圖。
基於Orangpi Zero和Linux ALSA實現WIFI無線音箱(二)