基于Orangpi Zero和Linux ALSA实现WIFI无线音箱

Posted qzrzq1

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了基于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无线音箱的主要内容,如果未能解决你的问题,请参考以下文章

Linux ALSA驱动之五:Linux ALSA驱动之Platform源码分析(基于Linux 5.18)

Linux ALSA源码分析(基于Linux 5.18)

Linux ALSA源码分析(基于Linux 5.18)

Linux ALSA源码分析(基于Linux 5.18)

Linux ALSA介绍

基于linux5.15.5的IMX 参考手册 --- 15