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

时间:2023-03-08 20:50:10

作品已经完成,先上源码:

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();
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();
m_ServerAddr.sin_addr = GetLocalIPAddr(); //把IP地址转为字符串并显示在编辑框中
char a[];
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,);
bind(m_socket,(SOCKADDR*)&m_ServerAddr,sizeof(SOCKADDR));//绑定套接字 u_long mode = ;
ioctlsocket(m_socket,FIONBIO,&mode);//设置为非阻塞模式(sendto函数立即返回) /*---------------------------------------------------------------------------------------------------------*/
//设置0.1s时长的音频缓冲区
BuffDuration_millisec = ; //初始化成员变量
pAudioClient = NULL;
pCaptureClient = NULL;
pwfx =NULL; /*---------------------------------------------------------------------------------------------------------*/
//对话框初始化在屏幕右下角位置
CRect dlg_windows,sysWorkArea;
SystemParametersInfo(SPI_GETWORKAREA,,&sysWorkArea,);
GetWindowRect(&dlg_windows);
SetWindowPos(NULL,sysWorkArea.right-dlg_windows.right, sysWorkArea.bottom-dlg_windows.bottom, , , 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,
,
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 = ; //缓冲区一次可以读取的帧数量,这个参数和上面那个的数值是一样的
//至于为什么要设两个,是因为使用的情况不一样
//上面那个是以函数返回值的形式返回,这个是以形参的形式跟缓冲区起始地址一起返回的
UINT32 numFramesAvailable = ; //标志位,指示静音什么的,这里不用
DWORD flags;
//这个是数据缓冲区,传递给函数的指针变量
BYTE *pData; //计数器,记录读了多少数据帧数据
UINT32 Counter=; //把音频格式结构体复制到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/; //获取音频捕获(录音)客户端
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.05s,时间间隔0.05s,即每隔0.05把数据读出来并发送
SetWaitableTimer(hTimerWakeUp,&FirstTime,(dlg->BuffDuration_millisec *) /,NULL, NULL, FALSE); //输出重定向到txt文件的方法,在命令行启动就可以看到调试信息,请参考https://blog.****.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)>>; //计数器从置,从第41个字节开始写音频数据 //线程休眠,一直录音,这里设置的时间要比BuffDuration_millisec短,因为后面复制数据也是需要时间的
//官方给的例程是大buff时间的一半。
//Sleep((dlg->BuffDuration_millisec * 5) / 10);
WaitForSingleObject(hTimerWakeUp,INFINITE); hr = dlg->pCaptureClient->GetNextPacketSize(&packetLength); //获取包长度,以帧为单位,这里获取的是小buff的数据包长度 //输出重定向到txt文件的方法,在命令行启动就可以看到调试信息,请参考https://blog.****.net/benkaoya/article/details/5935626
//printf("\nCounter:numFA: "); while (packetLength != )
{
//获取小buff的地址,同时获取帧数量,这个帧数量和上面的包长度数值是一样的
hr = dlg->pCaptureClient->GetBuffer(&pData,&numFramesAvailable,&flags, NULL, NULL); //输出重定向到文件的方法,可以看到调试信息,请参考https://blog.****.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)>>))
sendto(dlg->m_socket,(char*)DataToSend,Counter<<,,(SOCKADDR *)(&(dlg->m_ClientAddr)),sizeof(SOCKADDR));
} //停止环回录音
hr = dlg->pAudioClient->Stop(); CoTaskMemFree(dlg->pwfx);
SAFE_RELEASE(dlg->pAudioClient)
SAFE_RELEASE(dlg->pCaptureClient) return ;
}

五、最小化到系统托盘

  这一块内容就不说了,作者也是直接参考别人的代码稍作修改实现的,可以参考:https://www.cnblogs.com/suthui/p/3492962.html

六、写在最后

  本作品发送的音频数据都是未经压缩的PCM原始数据,这种方法的好处就是发送端接收端没有压缩和解码的过程,效率高,实时性好。缺点就是传输的数据量大,占用网络带宽,以作者的48kHz、2通道、16位深的音频数据为例,网络带宽占用195KB/s。以下是发送端运行截图及windows资源管理器网络速度截图。

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