作品已经完成,先上源码:
全文包含三篇,这是第二篇,主要讲述发送端程序的原理和过程。
第一篇:
第三篇:
以下是正文:
发送端程序基于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上的:,主要有两个地方要注意:
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;}
五、最小化到系统托盘
这一块内容就不说了,作者也是直接参考别人的代码稍作修改实现的,可以参考:
六、写在最后
本作品发送的音频数据都是未经压缩的PCM原始数据,这种方法的好处就是发送端接收端没有压缩和解码的过程,效率高,实时性好。缺点就是传输的数据量大,占用网络带宽,以作者的48kHz、2通道、16位深的音频数据为例,网络带宽占用195KB/s。以下是发送端运行截图及windows资源管理器网络速度截图。