【作业总结】声卡数据采集及处理

这学期开了网络化测控课,第二周开头就布置了一个相当有难度的作业:

以小组为单位,写一个声卡数据采集程序,功能要求:

  1. 以曲线形式显示波形;
  2. 利用数字滤波器对数据进行平滑滤波;
  3. 对声音信号进行 FFT 变化,计算信号的主频。

对于缺乏很多前置知识的我们专业的学生来说,这确实非常有难度。

到编写本文的时候,已经进行了三天,基本功能编写完成,还需要进一步优化,为了能够偷懒,为了让队员能够更加了解本次项目,以及我自己能够从中学到东西,撰写本文如下。

本文并不专业,作者本身不是控制专业,所以出现错误在所难免,本文不是教程,仅仅是一次作业的记录复盘,不能保证正确性。

码云仓库开源链接

参考链接

准备工作

还是得写啊,我先确认一下小组成员的配置。

小组总共四个人,leesin咸鱼米简白

我只和咸鱼米一起写过代码,大致了解她的水平。

预估编程能力:我 > leesin > 咸鱼米 > 简白;

对 git 了解程度: 我 ≈ leesin > 咸鱼米 > 简白;

硬件配置

简白没有带电脑,无法参与编程;

咸鱼米的电脑非常卡顿,存储空间也非常小,上学期写课设的时候,她甚至是把 eclipse 放在 U 盘里面打开的,不指望她能用 vs。

leesin 的电脑应该和我相当,目前没有出现过啥问题。

我的电脑以及网络应该是小组里面最好的,游戏本外加非常快的网络,看网课从来只有老师那边卡(说起这个就想起网络测控老师那边卡成壁纸的网速)

软件配置

首先应该会用到 windows 的 API,用 C++比较好,组员们最熟悉的也是它(大概吧),而且课设是做个小车,曾经接触过单片机编程,知道是需要用 C 来编程的,java、python 啥的别想用,所以最终选了 C++。

这次除了这上面的作业外,还有一个略简单的作业,PID 控制程序,这个就用 VC++6.0 来写了,照顾一下没有 vs 的咸鱼米,正好我也在学校机房写习惯了它。

但是写完 PID 之后,发现声卡数据采集程序要是拿 VC++6.0 来写,未知原因跑不通,加上调试起来确实没有 vs 方便,就决定这个项目还是用 vs 吧。

IDE 决定是 vs,接着是协作方式的问题,果断 git,平台的话,还是用国内的码云吧,毕竟要考虑网速问题。

在码云上建立了私有仓库,用 master-develop 分支结构。

时间轴

周二-2020-03-03

初步了解组员情况,分析题目要求。

在码云建立私有库,并邀请组员加入。PID 项目初始化。

周三-2020-03-04

了解了一下 PID 算法,然后交给 leesin 和咸鱼米去整了。

真正的难点在于声卡数据采集和处理这个项目。我们都对此非常不了解。

声卡数据采集

首先,需要采集声音信息。

该如何采集?我当时想到的是,应该是有 API 可以调用的,但是并没有查到那种讲解 API 的博客,能找到的只有官方文档:About WASAPI

在本次项目之前,我是不太喜欢读文档的,因为有很多讲解得很详细的博客,没理由去自己啃文档啊,而且一般那种时候我都是处于课设周,需要查询大量资料,没有时间去看英文文档,除非遇到看博客解决不了的问题。

这次只能看了,当然,还是得配合翻译插件(chrome 刚装彩云小译没几天就用上了,中英对照效果还不错)。

The Windows Audio Session API (WASAPI) enables client applications to manage the flow of audio data between the application and an audio endpoint device.

Windows 音频会话 API (WASAPI)使客户端应用程序能够管理应用程序和音频端点设备之间的音频数据流。

Header files Audioclient.h and Audiopolicy.h define the WASAPI interfaces.

头文件 Audioclient.h 和 audiopolis. h 定义了 WASAPI 接口。

懂了,想用这个 API 得先包含两个头文件,Audioclient.hAudiopolicy.h,不过在 vc++6.0 我编译不了,说没有这俩文件,但是 vs 可以,所以后来统一用了 vs。

接着看后面的说明,照着做但是不行。

比如,让我调用IMMDevice::Activate这个方法,写上去却找不到这个方法,说是::前面得是命名空间或者类。后来折腾了很久才发现,原来IMMDevice不是命名空间而是类名啊!

然而我还是不太清楚如何弄出来它说的那些客户端,各种参数太多了,不知道传啥。好在后面终于找到了一些有用的资料。

对于采集数据的流程和原理不是很明白,但是通过读文档以及后来找到的一些博客互相配合着理解,总算对整个流程有了一个大致的了解。

流程分为以下几步(Windows 上的音频采集技术:采集过程整体流程说明):

  • 创建多媒体设备枚举器(IMMDeviceEnumerator)
  • 通过多媒体设备枚举器获取声卡接口(IMMDevice)
  • 通过声卡接口获取声卡客户端接口(IAudioClient)
  • 通过声卡客户端接口(IAudioClient)可获取声卡输出的音频参数、初始化声卡、获取声卡输出缓冲区的大小、开启/停止对声卡输出的采集
  • 通过声卡采集客户端接口(IAudioCaptureClient)可获取采集的声卡输出数据,并对内部缓冲区进行控制

由于用到的函数太多了,就只给出函数官方文档链接,以及在代码中做出简单的注释。注释内容大部分为机翻。

为了清晰,没有加入错误处理的代码。

下面的示例代码解析自官方的示例程序Capturing a Stream,会有一些改动。

初始化

最开始,得使用CoInitialize函数来在当前线程上初始化 COM 库(CoInitialize 函数

1
CoInitialize(NULL);//初始化com库

采集结束后,记得关闭

1
CoUninitialize();

创建多媒体设备枚举器

定义一些常量

1
2
3
4
const CLSID CLSID_MMDeviceEnumerator = __uuidof(MMDeviceEnumerator);
const IID IID_IMMDeviceEnumerator = __uuidof(IMMDeviceEnumerator);
const IID IID_IAudioClient = __uuidof(IAudioClient);
const IID IID_IAudioCaptureClient = __uuidof(IAudioCaptureClient);

这些常量是这些类的 UUID,总之就是用来标识这些类的。

Cocreateinstance 函数

1
2
3
4
5
6
7
8
//创建多媒体设备枚举器
IMMDeviceEnumerator *pEnumerator = NULL;
CoCreateInstance(
CLSID_MMDeviceEnumerator, //创建与指定 CLSID (Class ID,即类标识符)关联的类的单个未初始化对象。
NULL,//如果为 NULL,则表示该对象不是作为聚合的一部分创建的
CLSCTX_ALL,//管理新创建对象的代码将在其中运行的上下文。 这些值取自枚举 CLSCTX
IID_IMMDeviceEnumerator,//对用于与对象通信的接口标识符的引用
(void**)&pEnumerator);//接收 riid 请求的接口指针的指针变量的地址。 成功返回后,* ppv 包含请求的接口指针。 失败时,* ppv 包含 NULL。

获取声卡接口

使用刚刚获取的枚举器来获取默认音频端点设备。

IMMDeviceEnumerator::GetDefaultAudioEndpoint 方法

1
2
3
4
5
6
//获取声卡接口
IMMDevice *pDevice = NULL;//声卡接口
pEnumerator->GetDefaultAudioEndpoint(
eCapture,//端点设备的数据流方向。 调用方应该将此参数设置为以下两个 EDataFlow 枚举值之一:eRender,eCapture,前者渲染,后者捕获
eConsole,//端点设备的角色。 调用者应该将这个参数设置为以下 ERole 枚举值之一:eConsole,eMultimedia,eCommunications
&pDevice);//指向一个指针变量,该方法将默认音频端点设备的端点对象的 immmdevice 接口的地址写入该指针变量

设置默认音频格式

这里用的是使用最小音频格式,也可以手动设置自己的音频格式。

WAVEFORMATEX 结构体

1
2
3
//获取音频格式
WAVEFORMATEX *pwfx = NULL;
pAudioClient->GetMixFormat(&pwfx);

获取声卡客户端

IMMDevice::Activate 方法

1
2
3
//通过声卡接口获取声卡客户端接口
IAudioClient *pAudioClient = NULL;
pDevice->Activate(IID_IAudioClient, CLSCTX_ALL, NULL, (void**)&pAudioClient);

初始化声卡客户端

IAudioClient::Initialize 方法

1
2
3
4
5
6
7
8
9
REFERENCE_TIME hnsRequestedDuration = REFTIMES_PER_SEC; //采样持续时间,单位100纳秒
pAudioClient->Initialize(
AUDCLNT_SHAREMODE_SHARED,//与其他设备共享音频端点设备
0,//选项
hnsRequestedDuration,//以时间值表示的缓冲区容量
0,//设备周期,共享模式下设为0
pwfx,//音频格式
NULL//指向session的GUID的指针,设置为NULL表示打开一个新session
);

REFTIMES_PER_SEC是一个宏,作为参考时间单位。100 纳秒 = 1e-7 秒,即这个宏定义的值。也就是说,上面的代码是采样 1 秒的意思。

1
2
3
// REFERENCE_TIME time units per second and per millisecond
#define REFTIMES_PER_SEC 10000000
#define REFTIMES_PER_MILLISEC 10000

获取捕获客户端

IAudioClient::GetService 方法

1
2
3
4
5
//获取捕获客户端
IAudioCaptureClient *pCaptureClient = NULL;
hr = pAudioClient->GetService(
IID_IAudioCaptureClient, //客户端接口ID
(void**)&pCaptureClient);

启动音频流

IAudioClient::Start

1
2
//启动音频流
m_pAudioClient->Start();

采集数据

启动音频流之后,就可以开始捕获数据了,音频流有一个缓冲区

流程如下:

  • 从缓冲区获取下一个数据包
  • 处理数据包
  • 释放缓冲区
  • 获取下一个数据包大小,循环直到缓冲区为空

获取数据包大小,以确定流中是否有数据。

1
2
3
4
5
6
7
UINT32 packetLength = 0;//数据包长度
BYTE *pData = NULL;//数据包首地址
UINT32 numFramesAvailable;//数据包中可用的音频帧数
DWORD flags;//缓冲区状态标志
vector<BYTE> recorder;//用于存储数据

pCaptureClient->GetNextPacketSize(&packetLength);//获取下一个数据包的大小

处理其中的数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
while (packetLength != 0)
{
//获取缓冲区中的数据
pCaptureClient->GetBuffer(
&pData,//数据包指针变量的地址
&numFramesAvailable, //数据包中可用的音频帧数
&flags, //缓冲区状态标志
NULL,
NULL
);

//判断是否静音
if (flags & AUDCLNT_BUFFERFLAGS_SILENT)
{
pData = NULL;
}
int dataSize = numFramesAvailable * 4;//可用帧数*4=BYTE数

//采集数据
for (int i = 0; i < dataSize; i++)
{
BYTE tem = pData[i];
recorder.push_back(pData[i]);//添加进自己实现准备好的数据数组中

}


//释放缓冲区
pCaptureClient->ReleaseBuffer(numFramesAvailable);

//获取下一个数据包大小
pCaptureClient->GetNextPacketSize(&packetLength);

}

当然,由于缓冲区会不断地进来数据,你可以加一个判断,读取了多少个数据包后退出循环,否则会无限循环。

关闭音频流

1
pAudioClient->Stop();

测试输出

1
2
3
4
for (int i = 0; i < recorder.size(); i++)
{
printf("%d\n", recorder[i]);
}

输出效果图

周三大概做到这里

周四-2020-03-05

周四主要将波形曲线画出来。

绘制波形

创建了一个 MFC 项目,并新建了一个类,主要是将上面说到的代码简单封装了一下,没有用到的暂时不显示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
//CRecorder.h
#pragma once

#include <iostream>
#include <fstream>
#include <vector>
#include <stdio.h>
#include <cmath>
#include <algorithm>
#include <dshow.h>
#include <Windows.h>
#include <winerror.h>
#include <mmdeviceapi.h>
#include <Functiondiscoverykeys_devpkey.h>

#include <Audioclient.h>
#include <Audiopolicy.h>
#include <complex>

using namespace std;

// REFERENCE_TIME time units per second and per millisecond
#define REFTIMES_PER_SEC 10000000
#define REFTIMES_PER_MILLISEC 10000

#define EXIT_ON_ERROR(hres) \
if (FAILED(hres)) { goto Exit; }
#define SAFE_RELEASE(punk) \
if ((punk) != NULL) \
{ (punk)->Release(); (punk) = NULL; }


class CRecorder
{
private:
vector<BYTE> m_recorder;//数据记录器
IAudioClient *m_pAudioClient;//声卡客户端
IAudioCaptureClient *m_pCaptureClient;//捕获流客户端
WAVEFORMATEX *m_pwfx;


public:
CRecorder();
//手动提取出来的代码
void init();//初始化
void refreshRecorder();//刷新采样数据
void onError(HRESULT hres);//错误处理(也没咋用,懒得写那么多错误处理)

HRESULT RecordAudioStream();//整块的示例代码,用于测试,现在不使用

~CRecorder();
int drawWaveform(CDC* pDC, CRect rect,vector<BYTE> output);//绘制图像

};


实现部分和上面差不多就不赘述了。

主要是绘制方面。

第三个参数vector<BYTE> output是为了后面的滤波所准备的,是由 leesin 提出的改进

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int CRecorder::drawWaveform(CDC* pDC,CRect rect,vector<BYTE> output)
{
//RecordAudioStream();
int height = rect.Height();
int width = rect.Width();
int x_coefficient = 5;
//int a = dataStart;
pDC -> MoveTo( 0, output[dataStart] );
for (int i = dataStart; i < output.size() && (i-dataStart+1)*x_coefficient <= width; i++)
{
pDC->LineTo((i-dataStart+1) * x_coefficient, output[i]);
}
return 0;
}

在对话框类中获取 pDC

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void CsoundcarddataacquisitionDlg::drawWaveform()
{
m_pPanel = GetDlgItem(IDC_PANEL);//获得静态窗口对象指针
//清屏
m_pPanel->ShowWindow(FALSE);//偷懒用的方法
m_pPanel->ShowWindow(TRUE);

//获取控件区域
CRect rect;
m_pPanel->GetClientRect(&rect);

//获取控件画笔
CDC* pDC = m_pPanel->GetDC();

//绘制原始采样数据
m_pPanel->UpdateWindow();
//m_recorder.RecordAudioStream();
m_recorder.refreshRecorder();
m_recorder.drawWaveform(pDC, rect,m_recorder.NoFiltering());


ReleaseDC(pDC);
}

其中:

1
2
3
4
vector<BYTE> CRecorder::NoFiltering()
{
return m_recorder;
}

第一次显示的图

滤波算法

老师给了个 txt,里面就是各种滤波算法,我也没啥精力去研究了,就交给 leesin 了,他完成得很不错,就是刚刚上面说的设计就是他整的。不过一开始用的算法效果不太好,让他继续研究。此时咸鱼米在弄 vc6.0 的 PID 那个项目,因为她没有 vs。

周五周六-2020-03-06~07

这两天都在学习那个 FFT 快速傅里叶变换

FFT

参考各方资料写出来这个递归版本的(迭代版本的看不懂),参考链接见本文开头。

  • 输入:多项式系数表示法的系数,值为时域下的幅值
  • 输出:多项式点值表示法的点(以复数表示),其模为频域下的幅值
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/*
*FFT
传入的复数数组里面都是实数,含义是多项式系数表示法的系数,值为时域幅值
系数数组长度得是2的整数次方
返回值的模为频谱幅值
*/
vector<complex<double>> CRecorder::FFT(vector<complex<double>> A)
{
const double PI = 3.141592651;
int len = A.size();
if (len == 1) return A;//递归结束条件
vector<complex<double>> A1, A2;//A(x) = A1(x^2) + x * A2(x^2)
//将系数分类
for (int i = 0; i < len; i++)
{
if (i % 2 == 0)
A1.push_back(A[i]);
else
A2.push_back(A[i]);
}

A1 = FFT(A1);
A2 = FFT(A2);

complex<double> Wn(cos(2.0*PI / len), sin(2.0*PI / len));//len等分点的角度增量
complex<double> W(1.0, 0.0);//用于遍历复平面单位圆上的len个等分点

for (int i = 0; i * 2 < len; i++, W *= Wn)
{
A[i] = A1[i] + W * A2[i];
A[i + len / 2] = A1[i] - W * A2[i];
}
return A;
}

完成之后的效果是下面这样的:

上图的坐标都是没有变换的,还是以左上角为原点。

发现重装系统前写的东西都没了

啊啊啊啊啊啊!后面那么一大段就这样没了!不太想补了。

其实核心部分也基本上说完了,剩下的就是坐标转化以及动态采样了,读者们可以移步本项目的码云仓库查看代码。

【作业总结】声卡数据采集及处理

https://yxchangingself.xyz/posts/sound-card-data-acquisition/

作者

憧憬少

发布于

2020-03-08

更新于

2020-03-08

许可协议