1. 程式人生 > >基於Orangpi Zero和Linux ALSA實現WIFI無線音箱(二)

基於Orangpi Zero和Linux ALSA實現WIFI無線音箱(二)

memfree 線程 取數據 edit 這一 緩沖 一起 說了 cpp

作品已經完成,先上源碼:

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無線音箱(二)