作品已经完成, 先上源码:
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 资源管理器网络速度截图.
来源: https://www.cnblogs.com/qzrzq1/p/9074132.html