音视频捕获输入(二)
试验以及实现
音频捕获部分
首先来明确我们需要做的工作:
- 打开一个音频文件(从视频文件中分离的),或是麦克风
- 获得文件的比特率,将音频文件截成1min左右的一段,发送至服务器
- 时间确定的标准是,选择一个比较安静的时刻,防止将说着的话截断
为了这些基本要求,我从stk的API中获得了如下必要对象和函数
FileWvIn类
用于导入文件并通过stk中定义的方式(tick)对文件进行循环
重要方法:
void stk::FileLoop::openFile ( std::string fileName, bool raw = false, bool doNormalize = true, bool doInt2FloatScaling = true )
载入一个音频文件作为输入
StkFloat stk::FileLoop::get(FileRate( void ) const
返回该类对象已经导入的音频文件的采样率
void stk::FileLoop::setRate( stkFloat frequency )
设置该类对象已经导入文件的采样率
StkFloat stk::FileLoop::tick(unsiged int channel = 0)
从输入文件的一个channel中计算得出一个采样点
virtual StkFrames& stk::FileLoop::tick(StkFrames & frames, unsiged int channel = 0)
从输入文件中返回一批采样点,填满StkFrames对象
FileWvOut类
用于将采样点输出至文件,同样用tick方式完成输出
void stk::FileWvOut::openFile ( std::string fileName, unsigned int nChannels, FileWrite::FILE_TYPE type, Stk::StkFormat format )
打开一个新文件
void stk::FileWvOut::closeFile( void )
关闭打开的文件,缓冲区会被写入文件
void stk::FileWvOut::tick(const StkFloat sample )
void stk::FileWvOut::tick(const StkFrames & frames )
将采样点写入文件
静态方法
static StkFloat sampleRate ( void )
获取当前全局采样率
static void setSampleRate ( StkFloat rate )
设置全局采样率
技术测试
为了熟悉该api,首先编写一段技术测试代码
/**
* Technical testing code
* @author Tecelecta
* @date 2018.4.14
*/
#include <iostream>
#include <unistd.h>
#include <errno.h>
#include <stk/FileWvIn.h>
#include <stk/FileWvOut.h>
using namespace std;
using namespace stk;
/**
* @brief usage
* 输出使用方法
*/
void usage()
{
cout << "Usage:\n";
cout << "\t./cam <input file> <output file>\n";
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
usage();
exit(-1);
}
if(access(argv[1],F_OK) == -1)
{
cout << "Error: Input file doesn't exists!\n";
exit(-1);
}
FileWvIn input;
FileWvOut output;
cout << "Current Sample rate : " << Stk::sampleRate() << endl;
input.openFile(argv[1]);
cout << "Sample rate after openning input: " << Stk::sampleRate() << endl;
output.openFile(argv[2],2,FileWrite::FILE_WAV,Stk::STK_SINT16);
cout << "Sample rate after openning output: " << Stk::sampleRate() << endl;
output.setSampleRate(input.getFileRate());
cout << "Sample rate after setting output: " << Stk::sampleRate() << endl;
uint32_t in_size = input.getSize();
try {
for(uint32_t i = 0; i < in_size; i++)
{
StkFloat frame = input.tick();
cout << "frame " << i << " : "<<frame << endl;
output.tick(frame);
}
} catch(StkError &){
cerr << "Error occurred while processing file!\n";
exit(-1);
}
output.closeFile();
return 0;
}
运行结果:确实申生成了输出文件
...
frame 209969 : -0.0475769
frame 209970 : -0.0481262
frame 209971 : -0.0489197
frame 209972 : -0.0490723
frame 209973 : -0.0482178
frame 209974 : -0.0486755
frame 209975 : -0.0474243
frame 209976 : -0.0474548
frame 209977 : -0.0453491
frame 209978 : -0.0448608
frame 209979 : -0.0446777
frame 209980 : -0.0466003
frame 209981 : -0.0451355
...
实时API的探索
题外讨论:
有了上面一系列技术测试,我们可以完成文件处理的类
具体设计上,由于语义分析与图像目标检测识别有明显差异——语义分析必然涉及时间上的连续性,不能向目标检测识别一样,可以将连续的视频剪切成图片逐一分析。根据stk的特点,我们可以像处理连续图片一样处理音频,按照一定的采样率从音频输入设备采样。这样我们就可以在读取的同时对数据进行检查,检查过程中如果发现用户希望中断录音,那么我们可以直接停止录音,不用涉及复杂的模块内组件通信
接着上面说到的检查,我们想要完整的录音分割成小段本质原因是语义分析的过程中输入数据量太大大量消耗资源,所以这里的检查过程,我们既是为了允许用户与系统的交互,又是为了在合适的时候切换输出文件
这里我们讨论了几种方法:
- 时长限制:每个文件长度控制在1min左右
- 信息量限制:文件中录下来的应该是完整的话,而且空白时间应该尽量少
时长的检测比较简单,因为我们有当前的采样率,只要通过帧数/采样率就可以算出时间
信息量的检测想要做到比较精确可能比较困难,但是我们可简单地将声音响度作为信息量判断的标准,如果连续一段时间内(如:1s)声音的响度没有达到一个特定值(通过正则化的样本点值判断)那么我们就关闭当前的文件,打开新的文件。
上述这些内容对文件处理均可实现,但是涉及到实时录音,就会发生很多问题,下面,我们简单介绍一下stk中实时音频处理的部分,并具体解释这些问题
RtAudio类
这个类是stk实时IO的基础类,直接调用了alsa等各种平台上的音频库,具有跨平台性,也是整个stk代码中唯一推荐预先编译的类。
这个类型实现了典型的异步回调处理方式:一个RtAudio对象逻辑上对应一个音频设备,当程序只能控制设备的启动和停止,而数据的传输由回调函数完成,设备调用回调函数的一个条件就是设备的缓冲区填满,无论是播音还是录音,只要设备调用回调函数,就可以完成数据的取放,函数返回后,设备可以继续工作
RtWvIn、RtWvOut类
这两个类是建立在RtAudio类基础上的两个更高层的类,从上面可以看出,RtAudio类和FileWvIn、Out完全是两种东西,互相兼容性很不好,所以对其进行了封装,使实时IO具有和文件IO相同的借口,本质上异步的通信方式,想要实现同步,只能通过阻塞来实现
- 根据官方文档的说明,RtWvIn、out实现是建立在巨大的缓冲区之上,参考源码,实际情况确实如此
RtWvIn :: RtWvIn( unsigned int nChannels, StkFloat sampleRate, int device, int bufferFrames, int nBuffers )
: stopped_( true ), readIndex_( 0 ), writeIndex_( 0 ), framesFilled_( 0 )
{
// We'll let RtAudio deal with channel and sample rate limitations.
RtAudio::StreamParameters parameters;
if ( device == 0 )
parameters.deviceId = adc_.getDefaultInputDevice();
else
parameters.deviceId = device - 1;
parameters.nChannels = nChannels;
unsigned int size = bufferFrames;
RtAudioFormat format = ( sizeof(StkFloat) == 8 ) ? RTAUDIO_FLOAT64 : RTAUDIO_FLOAT32;
try {
adc_.openStream( NULL, ¶meters, format, (unsigned int)Stk::sampleRate(), &size, &read, (void *)this );
}
catch ( RtAudioError &error ) {
handleError( error.what(), StkError::AUDIO_SYSTEM );
}
data_.resize( size * nBuffers, nChannels );
lastFrame_.resize( 1, nChannels );
}
RtWvIn :: ~RtWvIn()
{
if ( !stopped_ ) adc_.stopStream();
adc_.closeStream();
}
void RtWvIn :: start()
{
if ( stopped_ ) {
adc_.startStream();
stopped_ = false;
}
}
void RtWvIn :: stop()
{
if ( !stopped_ ) {
adc_.stopStream();
stopped_ = true;
for ( unsigned int i=0; i<lastFrame_.size(); i++ ) lastFrame_[i] = 0.0;
}
}
以RtWvIn为例,申请的缓冲区的大小为bufferFrames*nBuffers
(名义上说是缓冲区数量×缓冲区大小,实际上,用起来的时候根本没有那个缓冲区是哪个缓冲区)
为了探究这个类的特性,参考一下回调函数
// This function is automatically called by RtAudio to supply input audio data. int read( void *outputBuffer, void *inputBuffer, unsigned int nBufferFrames, double streamTime, RtAudioStreamStatus status, void *dataPointer ) { ( (RtWvIn *) dataPointer )->fillBuffer( inputBuffer, nBufferFrames ); return 0; }
首先函数签名中的内容必须一致,作为内容调用的函数就在下面
// This function does not block. If the user does not read the buffer // data fast enough, unread data will be overwritten (data overrun). void RtWvIn :: fillBuffer( void *buffer, unsigned int nFrames ) { StkFloat *samples = (StkFloat *) buffer; unsigned int counter, iStart, nSamples = nFrames * data_.channels(); while ( nSamples > 0 ) { // I'm assuming that both the RtAudio and StkFrames buffers // contain interleaved data. iStart = writeIndex_ * data_.channels(); counter = nSamples; // Pre-increment write pointer and check bounds. writeIndex_ += nSamples / data_.channels(); if ( writeIndex_ >= data_.frames() ) { writeIndex_ = 0; counter = data_.size() - iStart; } // Copy data to the StkFrames container. for ( unsigned int i=0; i<counter; i++ ) data_[iStart++] = *samples++; nSamples -= counter; } mutex_.lock(); framesFilled_ += nFrames; mutex_.unlock(); if ( framesFilled_ > data_.frames() ) { framesFilled_ = data_.frames(); oStream_ << "RtWvIn: audio buffer overrun!"; handleError( StkError::WARNING ); } }
这个函数做的工作也比较简单,仅仅是简单的从缓冲区中写入数据,因为这个缓冲区实际上是RtAudio的,所以这里加了个互斥(RtAudio中部分功能通过多线程实现)
综合上述几个部分,这个类的实现方式就比较清楚了,这两个类仅在用户处理数据足够快的情况下才有阻塞,如果用户处理数据能力不足,就会发生缓冲区回转
使用这个类有一定的风险:
官方文档中明确说明,如果对实时性要求高的话,不应该使用这个类
鉴于这部分是要部属在嵌入式设备上的,我们不得不考虑嵌入式设备的算力是否足以胜任实时音频的处理
综合讨论,我们认为使用阻塞api的更好,因为:
实现功能相对简单,不需要增加相关的封装就可以和上面提到的文件处理形成统一api
如果树莓派不能胜任实时处理,我们也有如下的处理方法
- 降低采样率
- 更换性能更强的嵌入式设备
这些处理方法的成本都比较低,可以接受
下面是更新后的技术测试代码
/**
* Technical testing code
* @author Tecelecta
* @date 2018.4.14
*/
#define __OS_LINUX__
#include <iostream>
#include <unistd.h>
#include <errno.h>
#include <stk/FileWvIn.h>
#include <stk/FileWvOut.h>
#include <stk/RtWvIn.h>
#include <stk/RtWvOut.h>
//#define RT_OUT
#define RT_IN
using namespace std;
using namespace stk;
/**
* @brief usage
* 输出使用方法
*/
void usage()
{
cout << "Usage:\n";
cout << "\t./cam <input file> <output file>\n";
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
usage();
exit(-1);
}
if(access(argv[1],F_OK) == -1)
{
cout << "Error: Input file doesn't exists!\n";
exit(-1);
}
uint32_t in_size;
#ifdef RT_IN
RtWvIn input(1,Stk::sampleRate(), 8, 20);
in_size = 100000;
#else
FileWvIn input;
input.openFile(argv[1]);
in_size = input.getSize();
Stk::setSampleRate(input.getFileRate());
#endif
#ifdef RT_OUT
RtWvOut *output;
output = new RtWvOut(1, Stk::sampleRate(), 6, RT_BUFFER_SIZE, 20);
#else
FileWvOut *output;
output = new FileWvOut(argv[2]);
#endif
try {
for(uint32_t i = 0; i < in_size; i++)
{
StkFloat frame = input.tick();
cout << "frame " << i << " : "<<frame << endl;
output->tick(frame);
}
} catch(StkError &){
cerr << "Error occurred while processing file!\n";
exit(-1);
}
#ifdef RT_OUT
delete output;
#else
output->closeFile();
#endif
return 0;
}
通过修改宏定义,可以编译出使用不同功能的代码,经过实验都可以使用
下面重点解释一下设备编号的问题:
我本来认为使用默认设备编号就可以了,但是无论是使用官方的示例代码还是修改自己的代码,设备永远用不了——输出没有声音,输入更是打都打不开。
仔细阅读了官方文档,发现可以通过RtAudio类获取设备名称,我便尝试了一下,发现默认输入输出的名称分别是这个样子的
- 输入:hw:HDA Intel HDMI,3
- 输出:hw:HDA Intel HDMI,3
后来查阅了相关资料,找到了alsa的控制台alsamixer
┌────────────────────────────── AlsaMixer v1.1.0 ──────────────────────────────┐
│ Card: HDA Intel HDMI F1: Help │
│ Chip: Intel Haswell HDMI F2: System information │
│ View: F3:[Playback] F4: Capture F5: All F6: Select sound card │
│ Item: S/PDIF Esc: Exit │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
│ │OO│ │OO│ │OO│ │OO│ │OO│ │
│ └──┘ └──┘ └──┘ └──┘ └──┘ │
│ < S/PDIF >S/PDIF 1 S/PDIF 2 S/PDIF 3 S/PDIF 4 │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
这就非常诡异了,这台机器上装好了alsa,默认音频输出竟然是HDMI,那么内置声卡在哪里呢?按下F6
┌────────────────────────────── AlsaMixer v1.1.0 ──────────────────────────────┐
│ Card: HDA Intel HDMI F1: Help │
│ Chip: Intel Haswell HDMI F2: System information │
│ View: F3:[Playback] F4: Capture F5: All F6: Select sound card │
│ Item: S/PDIF Esc: Exit │
│ │
│ │
│ │
│ │
│ ┌───── Sound Card ──────┐ │
│ │- (default) │ │
│ │0 HDA Intel HDMI │ │
│ ┌──┐ │1 HDA Intel PCH │ ┌──┐ │
│ │OO│ │ enter device name...│ │OO│ │
│ └──┘ └───────────────────────┘ └──┘ │
│ < S/PDIF >S/PDIF 1 S/PDIF 2 S/PDIF 3 S/PDIF 4 │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
┌────────────────────────────── AlsaMixer v1.1.0 ──────────────────────────────┐
│ Card: HDA Intel PCH F1: Help │
│ Chip: Realtek ALC3239 F2: System information │
│ View: F3:[Playback] F4: Capture F5: All F6: Select sound card │
│ Item: Master [dB gain: -40.50] Esc: Exit │
│ │
│ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ ┌──┐ │
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ │
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ │
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ →
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ →
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ →
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ →
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ →
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ →
│ │ │ │ │ │▒▒│ │▒▒│ │ │ │ │ →
│ │▒▒│ │ │ │▒▒│ │▒▒│ │ │ │ │ │
│ │▒▒│ │ │ │▒▒│ │▒▒│ │ │ │ │ │
│ ├──┤ ├──┤ ├──┤ └──┘ ├──┤ └──┘ ┌──┐ ┌──┐ │
│ │OO│ │MM│ │OO│ │MM│ │MM│ │OO│ │
│ └──┘ └──┘ └──┘ └──┘ └──┘ └──┘ │
│ 14 0<>0 100<>100 98<>98 0<>0 0<>0 │
│ < Master >Headphon Speaker PCM Mic Mic Boos S/PDIF S/PDIF D │
└──────────────────────────────────────────────────────────────────────────────┘
感觉对劲了很多,看来真正的输入输出不是标准输入输出,那只能自己确定了
#include <stk/RtAudio.h>
#include <iostream>
using namespace std;
int main()
{
RtAudio dev;
cout << "default in :" << dev.getDeviceInfo(dev.getDefaultInputDevice()).name << endl;
cout << "default out : " << dev.getDeviceInfo(dev.getDefaultOutputDevice()).name << endl;
for(int i = 0; i < 20; i++)
cout << "dev " << i << " : " << dev.getDeviceInfo(i).name << endl;
}
输出这个样子:
default in :hw:HDA Intel HDMI,3
default out : hw:HDA Intel HDMI,3
dev 0 : hw:HDA Intel HDMI,3
dev 1 : hw:HDA Intel HDMI,7
dev 2 : hw:HDA Intel HDMI,8
dev 3 : hw:HDA Intel HDMI,9
dev 4 : hw:HDA Intel HDMI,10
dev 5 : hw:HDA Intel PCH,0
dev 6 : hw:HDA Intel PCH,1
dev 7 : default
terminate called after throwing an instance of 'RtAudioError'
what(): RtApiAlsa::getDeviceInfo: device ID is invalid!
非常神奇地发现默认设备号竟然是7,后来带入试验,发现5号7号(输入的是6和8,因为创建RtWv对象的时候,设备号只要不是默认号就会-1),判断这种情况6号设备才是板载声卡。
清楚了这些我们就可以正式开始类的设计了
音频捕获类的设计与实现
沿袭视频捕获类实现的优良传统,我们依旧采类封装这些对象,实现一个对象完成一套流程
公共父类中成员变量的依旧是最基本信息
- 文件前缀:同一次业务流程生成的文件应该具有相同的前缀,这里我们依旧考虑使用时间戳,在对象建立的时候生成
- 计数器:前面已经提到,我们可以通过帧数和采样率计算时间,所以计数器记录的就是存入文件的帧数
父类公共功能接口定义
- run_tick() : 从输入中取出一个frame, 放入输出文件
- hasNextTick() : 是否还需再次run_tick
类定义:
#ifndef VOICAP_H
#define VOICAP_H
#define __OS_LINUX__
#include <stk/FileWvOut.h>
#include <stk/FileWvIn.h>
#include <stk/RtWvIn.h>
#include <stk/Stk.h>
using namespace std;
using namespace stk;
/* 与噪音识别有关的数据 */
#define DEFAULT_ENV_FACTOR 1.2
#define DEFAULT_QUIET_THRESH 200
#define DEFAULT_RECALC_RATIO 300
#define DEFAULT_RAISE_RATIO 1000
/**
* @brief The VoiCap class
* 定义了视频捕获的公共接口,对操作进行简单封装,目的是将输入和输出封装到一个对象里
*/
class VoiCap
{
public:
VoiCap( uint32_t nBuffer = 200,
const StkFloat env_factor = DEFAULT_ENV_FACTOR,
const uint32_t quiet_thresh = DEFAULT_QUIET_THRESH,
const uint32_t recalc_ratio = DEFAULT_RECALC_RATIO,
const uint32_t raise_ratio = DEFAULT_RAISE_RATIO);
virtual ~VoiCap(); //析构,对于父类来说并没有什么内容
int run_tick(); //采样接口,每次运行,都会从输入源采样一个波形
virtual bool hasNextTick() = 0; //子类中必然会定义这个方法,将自己的终止条件暴露给用户
virtual bool start(); //准备开始录制时的触发条件,初始化输入输出
virtual bool finish(); //录制结束或者条件触发时,调用这个方法完成输出文件的关闭
protected:
/* 基本成员变量 */
string prefix; //当前会话使用的文件前缀
uint32_t frame_cnt; //当前文件中输入的帧数,到一分钟附近文件将被断开
uint32_t total_cnt; //当前会话中转移的总帧数
uint32_t file_cnt; //本次会话使用的文件个数,形成文件名前缀
FileWvOut *output; //输出类对象
WvIn *input; //输入类的公共父类指针
/* 环境噪音相关变量 */
StkFloat noise_level; //噪音等级,将作为识别有效声音的标准
StkFloat sum_noise; //被识别为环噪的波形的加和
StkFloat sum_sig; //被连续识别为信号的波形的加和
uint32_t noise_cnt; //环噪波形计数
uint32_t sig_cnt; //连续的信号计数
const StkFloat _env_factor; //环境噪音识别率,振幅在noise_level*env_factor范围内的波形会被识别为环境噪音
const uint32_t _quiet_thresh; /* 安静阈值,如果连续的"安静"波数达到这个阈值,那么将暂停输出到文件*/
const uint32_t _recalc_thresh; /* 重新计算频率,如果被识别为环境噪音的波形数量达到这个值,
那么用这些值的平均值作为新的环境噪音等级 */
const uint32_t _raise_thresh; /* 提升频率,如果距离上一次被识别为噪音的点的波形数达到这个值,
那么我们将提升环境噪音等级到这些被认为是有效值的样本点的平均值 */
/* 输出重定向相关方法 */
bool need_redirect; //记录当前是否需要重定向的标识
int redirect(); //两个子类对输出文件重定向的操作有共同部分,这里就是简单的对重定向方法进行一个集中
/**
* @brief The WaveBuffer class
* 在噪音处理上,我们需要一定的措施,这就要求按照周期处理输入样本点,这个类就是辅助我们处理的
*/
class WaveBuffer
{
public:
/* 可以内联的构造析构 */
/**
* @brief WaveBuffer
* 构造函数,完成所有成员的初始化
* @param size
*/
inline WaveBuffer(uint32_t size) :
stat(BufferState::INIT), lastTick(0),
buf_sz(size), tick_ind(0), dump_ind(0),
profiled(false), level(0)
{
buf = new StkFloat[size];
}
/**
* @brief ~WaveBuffer
* 删除buf即可完成析构
*/
inline ~WaveBuffer()
{
delete buf;
}
int nextWave(StkFrames &out, WvIn &input); //从输入文件取出一个周期,放入数组(管他到底是不是一个周期有正有负有极大极小值就行)
StkFloat getLevel(); //返回上一个周期的振幅(绝对值最大的样本点的绝对值)
private:
/*表示当前缓冲区的状态*/
enum BufferState {
INIT, //缓冲区刚刚建立时的状态,会以第一个样本点的符号作为自己上半段的符号
ABOVE_HALF, //上半段状态,即波形处于上半周期,输入帧符号变化后转换到下半段状态
BELLOW_HALF //下半段状态,波形处于后半周期,输入帧符号变化后,讲缓冲内容导出,进入上半段状态
} stat;
StkFloat lastTick; //上一个样本点的值,用来判断符号变化
/*缓冲区管理成员*/
StkFloat* buf; //缓冲区指针
uint32_t buf_sz; //缓冲区大小
uint32_t tick_ind; //缓冲区输入指针
uint32_t dump_ind; //缓冲区输出指针
/*用于分析的相关成员*/
bool profiled; //上一个周期是否被分析过,如果没被分析过,就拒绝获取下一个周期
StkFloat level; //上一个周期的振幅
/*辅助方法*/
/**
* @brief _put_val
* 向环状缓冲区中填充样本,返回缓冲区是否已满
* @param val 放入缓冲区中的值
* @return 缓冲区是否已满
*/
inline bool _put_val(StkFloat val)
{
buf[tick_ind++] = val;
tick_ind %= buf_sz;
return tick_ind == dump_ind;
}
/**
* @brief _dump
* 重新设置输出数组大小并将缓冲区中的数据按顺序输入
* @param out 输出数组
*/
inline void _dump(StkFrames &out, int cnt)
{
out.resize(cnt,1,0);
int i = 0;
while(dump_ind != tick_ind)
{
out[i++] = buf[dump_ind++];
dump_ind %= buf_sz;
}
}
} _buffer;
};
class AudioCap : public VoiCap
{
public:
AudioCap( std::string fileName,
uint32_t nBuffer = 200,
const StkFloat env_factor = DEFAULT_ENV_FACTOR,
const uint32_t quiet_thresh = DEFAULT_QUIET_THRESH,
const uint32_t recalc_ratio = DEFAULT_RECALC_RATIO,
const uint32_t raise_ratio = DEFAULT_RAISE_RATIO);
~AudioCap();
bool hasNextTick();
bool start();
bool finish();
protected:
int redirect(); //这里需要多做一些工作,统计总共输出的帧数
private:
FileWvIn *my_input; //文件输入类
std::string inputName; //输入文件名称
};
class MicCap : public VoiCap
{
public:
MicCap(int dev,
uint32_t nBuffer = 200,
const StkFloat env_factor = DEFAULT_ENV_FACTOR,
const uint32_t quiet_thresh = DEFAULT_QUIET_THRESH,
const uint32_t recalc_ratio = DEFAULT_RECALC_RATIO,
const uint32_t raise_ratio = DEFAULT_RAISE_RATIO);
~MicCap();
bool hasNextTick();
bool start();
bool finish();
private:
RtWvIn *my_input; //音频设备输入类
int dev_id; //音频设备号
bool started; //输入是否启动
};
#endif // VOICAP_H
在去噪和截断上,我进行了不少的设计,在此简单陈述:
我们的目标是:
- 由于API对输入音频1min的长度限制,我们需要在合适的时候将声音截断,这个合适的时候需要坚决避免一个字说了一般的情况,尽量避免一句话说了一半的情况。
- 出于对计算资源和网络的考虑,我们希望上传的语音都是有效语音,无效的环境噪音应该尽量小,即当没有人说话的时候,录音应该暂停
为了实现上面的目标,我们需要的条件实际上只有一个——区别环境噪音和人声
初期我们的实现方法是使用响度来区别环境音和人声,这是基于环境噪音相对较低的假设。基于这个假设,对一段音频,我们需要进行如下几个方面来确定如何处理:
- 如何从音频中提取响度这个物理量
- 音频的响度满足什么条件后是噪音,什么条件是人声?
- 为了保证声音的可识别性,噪音我们如何处理,人声我们又如何处理?
首先回答第一个问题:
我们使用的stk是以样本点为基本单位进行处理的,对我们的目标来说这当然是不足的,这就是内部类VoiCap::WaveBuffer
存在的理由。声波不管再怎么叠加,都是周期性的,而一个周期内,振幅一定是确定的,那么就可以讲一个周期的波形作为振幅提取的最基本单位。这个类完成的工作,就是从输入源中以周期为单位提取样本点,方法是根据音频的“上下文”信息,来确定当前取出的一个样本点足够一个周期。这个思路有好处和复杂性:
- 好处:一个周期内,波形是完整的,即周期为单位处理可以很好的避免一个字说一半被截断的情况
- 复杂性:由于一段音频中声音的频率是不断变化的,一个周期的波形长度是变化的,我们需要动态确定输出长度
初期的实现中,我们通过样本点符号变化来判断周期,即2次变号,完成一个周期
第二个问题:
响度是一个简单的线性量,我们只要确定一个阈值就可以了,当然,为了程序的灵活性,我们不能使用绝对边界,因为一段音频中环境可能是变化的,所以我们动态阈值:根据输入上下文不断调整的阈值
我们认为,这样的动态阈值具有两个特点:
- 模糊性:分类时不严格按照阈值分类,在阈值附近的样本会被划入没有超过阈值的类
- 自调整性:通过累积与输入音频相关的知识来不断调整自己的值,可用的知识包括两个方面:
- 阈值以下的样本:根据我们的模糊性原则,这一类样本中既有超过阈值的样本,又有低于阈值的样本,我们通过这些值可以频繁校准阈值
- 阈值以上的样本:这些点可以在某些条件下大幅度校准阈值,我们认为这样的条件是:长时间没有阈值以下的样本出现。对应到实际场景中,可能是环境突然变得嘈杂,这种情况下我们可以通过突然提高的环境音量对阈值进行校准
这样一来会产生大量的参数,如:达到什么条件微调,达到什么条件后大调,这是我们在实现过程中需要确认的事项
第三个问题:
为了保证我们的截断尽量不影响人声,我们去噪和截断的过程中将使用缓冲区方式——连续多个样本没有达到阈值后,才按环境安静处理,也就是暂停录音,截断文件。
这个安静缓冲区的长短,又是一个参数
回答完这三个问题,产生了许多我们不好确定的参数,真正确定最佳值,我们认为是一个调试问题,上面讨论的具体实现在下面的代码中展示
#include "voicap.h"
#include <time.h>
#include <unistd.h>
using namespace stk;
/***********************************************************************
*
* VoiCap类方法实现
*
***********************************************************************/
/**
* @brief VoiCap::VoiCap
* 父类的成员的初始化:
* - 获取了文件前缀
* - 输出对象的建立和输出文件
* @param nBuffer 内部WaveBuffer的大小
* @param env_factor 环噪因数
* @param quiet_thresh 安静阈
* @param recalc_ratio 重算阈
* @param raise_ratio 提升阈
*/
VoiCap::VoiCap( uint32_t nBuffer ,
const StkFloat env_factor,
const uint32_t quiet_thresh,
const uint32_t recalc_ratio,
const uint32_t raise_ratio) :
frame_cnt(0), file_cnt(0),
noise_level(0), sum_noise(0), sum_sig(0), noise_cnt(0), sig_cnt(0),
_env_factor(env_factor), _quiet_thresh(quiet_thresh), _recalc_thresh(recalc_ratio), _raise_thresh(raise_ratio),
need_redirect(false),_buffer(nBuffer)
{
time_t t = time(NULL);
char t_str[64];
strftime(t_str, sizeof(t_str), "%Y-%m-%d_%H:%M:%S_", localtime(&t));
prefix = std::string(t_str);
output = new FileWvOut();
}
/**
* @brief VoiCap::~VoiCap
* 释放输出类对象的空间
*/
VoiCap::~VoiCap()
{
delete output;
}
/**
* @brief VoiCap::redirect
* 当输出变更条件触发时,变更输出对象
* @return 输出变更是否成功
*/
int VoiCap::redirect()
{
try
{
output->closeFile();
output->openFile(prefix + std::to_string(++file_cnt), 1, FileWrite::FILE_WAV, Stk::STK_SINT16);
frame_cnt = 0;
} catch (StkError &) {
return -1;
}
return 0;
}
/**
* @brief VoiCap::start
* 启动输入,这里父类做的仅仅是完成输出文件的打开,子类中可能还需要完成输入源的启动操作
* @return 操作是否成功
*/
bool VoiCap::start()
{
try
{
output->openFile(prefix + std::to_string(file_cnt), 1, FileWrite::FILE_WAV, Stk::STK_SINT16);
} catch ( StkError & ) {
return false;
}
return true;
}
/**
* @brief VoiCap::finish
* 结束输入,这里父类做的仅仅是关闭输出,子类中完成其他操作
* @return 是否正确结束,对于本类来说
*/
bool VoiCap::finish()
{
try
{
output->closeFile();
} catch ( StkError & ) {
return false;
}
return true;
}
/**
* @brief VoiCap::run_tick
* 文件输入的倒采样点方法,从一个缓冲区移动到另一个缓冲区,不存在计算量和实时性的问题
*
* 我们使用变量noise_level来表示,如果_quiet_thresh个波内没有明显超过噪声等级( > noise_level*_env_factor )的波形,
* 我们认为接下来进入安静时间并停止采样,停止采样后,第一个超过该值的波形将使采样继续
*
* noise_level动态确定:当样本点被视为环境噪声时,我们记录下这个样本点的值,当环境噪音数量累计达到_recalc_ratio的时候,
* 我们用累计值的平均值作为修正后的环噪等级
*
* @return 对取出帧的处理结果
* -1 : 发生错误
* 1 : 获取成功,放入输出缓冲区
* 0 : 获取成功,没有放入输出缓冲区
*/
int VoiCap::run_tick()
{
StkFrames frames;
StkFloat level;
int fcnt = _buffer.nextWave(frames, *input);
level = _buffer.getLevel();
total_cnt += fcnt;
if(level < noise_level*_env_factor)
{
//环噪相关计数增加
sum_noise += level;
noise_cnt++;
//信号相关计数清0
sum_sig = 0.0;
sig_cnt = 0;
//如果达到重算阈
if(noise_cnt == _recalc_thresh)
{
//重新计算环噪
noise_level = sum_noise / _recalc_thresh;
//计数清0
noise_cnt = 0;
sum_noise = .0;
} else if(noise_cnt >= _quiet_thresh) {
//停止采样,如果时机合适,进行重定向
if(need_redirect)
{
cout << "redircecting output file " << file_cnt << endl;
need_redirect = false;
return redirect();
} else {
return 0;
}
}
} else {
//信号相关计数增加,环噪相关数据就不用清0了
sum_sig += level;
sig_cnt++;
if(sig_cnt == _raise_thresh)
{
//达到提升阈,重新计算
noise_level = sum_sig / _raise_thresh;
//计数清0
sig_cnt = 0;
sum_sig = .0;
}
}
frame_cnt += fcnt;
output->tick(frames);
/*debug*/
//cout << "time: " << total_cnt/Stk::sampleRate() << " level: " << level << " noise_level: " << noise_level <<endl;
if( frame_cnt >= 50 * (int) Stk::sampleRate() )
{
need_redirect = true;
}
return 1;
}
/*******************************************************************
*
* VoiCap::WaveBuffer 子类方法实现
*
*******************************************************************/
#define SAME_SIDE(a,b) ( (a) < 0 && (b) > 0 ) || ( (a) > 0 && (b) < 0 )
#define ABS(a) ( (a)>0 ? (a) : -(a) )
/**
* @brief VoiCap::WaveBuffer::nextWave
* 从输出中取出一个波形,并返回波形的振幅
* @param out 输出数组
* @param input 输入对象
* @return 取出波形的长度,-1表明没有按顺序操作所以没取出来
*/
int VoiCap::WaveBuffer::nextWave(StkFrames &out, WvIn &input)
{
int cnt = 0; //用来记录这个周期的帧数
if(stat == BufferState::INIT) //如果是初始化阶段,那么就取一个样本点进来,完成初始化
{
lastTick = input.tick();
stat = WaveBuffer::ABOVE_HALF;
profiled = true;
while(lastTick == 0)
{
lastTick = input.tick();
}
}
//还没有对上一个周期进行过分析,直接返回
if(!profiled)
{
out.resize(0,0,0);
return -1;
}
//将上一个周期完成时取出的的样本点(符号变化的点)存入缓冲区,作为本周期第一个样本点
_put_val(lastTick);
cnt++;
//循环获取样本点
StkFloat next_tick;
while(1)
{
next_tick = input.tick();
//更新振幅
if(level < ABS(next_tick))
level = ABS(next_tick);
//判断是否符号发生变化
if(SAME_SIDE(lastTick, next_tick))
{
//发生变化,完成状态转换
if(stat == WaveBuffer::ABOVE_HALF)
{
stat = WaveBuffer::BELLOW_HALF;
} else {
stat = WaveBuffer::ABOVE_HALF;
profiled = false;
break;
}
}
//将采集的帧放入缓冲区,并报告缓冲区已满的情况
if(_put_val(next_tick))
{
std::cout << "Warning: buffer overrun!\n";
int cnt = 0;
for (int i = tick_ind; i != tick_ind -1 ; i++)
{
i %= buf_sz;
cout << buf[i] << ",\t";
if(++cnt % 5 == 0)
cout<<endl;
}
cout << endl;
cnt++;
break;
}
cnt++;
lastTick = next_tick;
}
lastTick = next_tick;
//向缓冲区中写入
_dump(out, cnt);
return cnt;
}
/**
* @brief VoiCap::WaveBuffer::getLevel
* 返回上一个波形的振幅,并清空振幅,方便下一个波形的统计
* @return 上一个波形的振幅
*/
StkFloat VoiCap::WaveBuffer::getLevel()
{
StkFloat ret = level;
level = 0;
profiled = true;
return ret;
}
/***********************************************************************
*
* AudioCap 类方法实现
*
***********************************************************************/
/**
* @brief AudioCap::AudioCap
* 初始化这个类中的成员变量
* @param fileName 输入文件的文件名
* 以下参数见父类构造函数说明
* @param nBuffer
* @param env_factor
* @param quiet_thresh
* @param recalc_ratio
* @param raise_ratio
*/
AudioCap::AudioCap(std::string fileName,
uint32_t nBuffer,
const StkFloat env_factor,
const uint32_t quiet_thresh,
const uint32_t recalc_ratio,
const uint32_t raise_ratio) :
VoiCap(nBuffer, env_factor, quiet_thresh, recalc_ratio, raise_ratio), inputName(fileName)
{
input = my_input = new FileWvIn();
}
/**
* @brief AudioCap::~AudioCap
* 析构函数,删除掉在子类方法中申请的空间
*/
AudioCap::~AudioCap()
{
delete my_input;
}
/**
* @brief AudioCap::start
* 文件输入类的start方法,打开输入文件即可
* @return start流程是否成功结束
*/
bool AudioCap::start()
{
try
{
my_input->openFile(inputName);
} catch ( StkError & ) {
return false;
}
return VoiCap::start();
}
/**
* @brief AudioCap::finish
* 文件输入类的finish方法,关闭输入文件
* @return finish流程是否成功结束
*/
bool AudioCap::finish()
{
try
{
my_input->closeFile();
} catch ( StkError & ) {
return false;
}
return VoiCap::finish();
}
/**
* @brief AudioCap::hasNextTick
* 对文件输入类来说,想要知道下一帧是否存在,只需要检查一下文件大小和当前已经获得的帧数就可以了
* @return 是否还有下一帧
*/
bool AudioCap::hasNextTick()
{
int tf = my_input->getSize();
return total_cnt < tf;
}
/***********************************************************************
*
* MicCap 类方法实现
*
***********************************************************************/
/**
* @brief MicCap::MicCap
* @param dev
* @param nBuffer
* @param env_factor
* @param quiet_thresh
* @param recalc_ratio
* @param raise_ratio
*/
MicCap::MicCap(int dev,
uint32_t nBuffer,
const StkFloat env_factor,
const uint32_t quiet_thresh,
const uint32_t recalc_ratio,
const uint32_t raise_ratio) :
VoiCap(nBuffer, env_factor, quiet_thresh, recalc_ratio, raise_ratio), dev_id(dev)
{
input = my_input = new RtWvIn(1, Stk::sampleRate(), dev);
}
/**
* @brief MicCap::~MicCap
* 析构函数,删除my_input对象
*/
MicCap::~MicCap()
{
delete my_input;
}
/**
* @brief MicCap::start
* 启动实时输入流,设备开始向缓冲区中传送数据
* @return 启动是否成功
*/
bool MicCap::start()
{
my_input->start();
started = true;
return VoiCap::start();
}
/**
* @brief MicCap::finish
* 停止设备输入,设备会停止向缓冲区输送数据
* @return 停止是否成功
*/
bool MicCap::finish()
{
my_input->stop();
started = false;
return VoiCap::finish();
}
/**
* @brief MicCap::hasNextTick
* 对于实时输入来说任何时候都有下一帧,想要停止,必须通过外界干涉
* @return
*/
bool MicCap::hasNextTick()
{
return started;
}
试验代码
下面是对这些定义的测试
// rtsine.cpp STK tutorial program
#define __OS_LINUX__
#include "voicap.h"
#include <cstdlib>
#include <iostream>
using namespace stk;
int main(int argc, char* argv[])
{
if(argc != 2)
{
cout<< "Usage: ./cam <input dir>\n";
exit(0);
}
// Set the global sample rate before creating class instances.
Stk::setSampleRate( 44100.0 );
Stk::showWarnings( true );
AudioCap cap(argv[1], 4000);
//MicCap cap(6, 4000);
int total=0, noise=0, sig=0;
cap.start();
while(cap.hasNextTick())
{
switch(cap.run_tick())
{
case 0: noise++; break;
case 1: sig++; break;
}
total++;
}
cout<< "total: " << total <<" sig "<<sig<<" noise "<<noise;
return 0;
}
至此,音频捕获的基础设施实现告一段落
小结
stk的基础类的源码虽然没有通读,但是看得出,这个工具包的设计比较简单,而且重点在音频渲染上,不过对于完成我们的工作,还是足够的。下面我们会对音视频捕获的基础设施再做完善,并将重点放在系统整体的搭建上
改进思路
首先,这里有许多标准物理量选择的有问题:
- 比起响度,频率是判断人声的更优方式
- 一个周期的确定,仅仅看变号会带来很大的问题,环境中声音是多种波形叠加而成的,虽然我们对周期要求并不严格,但是出现了一个周期长的出奇导致缓冲区溢出的情况,这是我们需要避免的