音频频谱动画的原理与实现

Posted 张东轩

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了音频频谱动画的原理与实现相关的知识,希望对你有一定的参考价值。

背景

微信的语音消息的按住说话,通过动画反馈出用户输入声音的大小,得到了比较好的效果,增强了用户体验。

微信的录音反馈做的很不错,但并没有表现出音频的频域信息,那么如何表示出声音的频率信息呢?

基础知识

音频基础知识可以参考:音频基本概念,下面只列举和本文有关的几个概念。

采样

在信号处理中,采样就是将连续时间的信号减少成离散时间的信号。
声音是一种连续压力波,通过对声音定时采样可以得到计算机能表示离散的数据,采样的频率被称采样率(Sample Rate)

为了不失真地恢复模拟信号,采样频率应该不小于模拟信号频谱中最高频率的2倍,这个定理被称为采样定理又被称为奈奎斯特采样定理(Nyquist–Shannon sampling theorem)

量化

将采样的结果表示成数据的过程被称为量化,这个数值范围被称为位深(bit depth)表示数值位数,常见的位数有8bit和16bit,位数越大对信号表达就越精确。

响度

声强亦称声强声强度,反映的是声音的客观物理强弱,决定于发音体振动的振幅,振幅越大,音强越强。
响度(loudness),又称音量,是量度声音大小的知觉量,与声强不同,响度是受主观知觉影响的物理量。在同等声强下,不同频率的声音会造成不同的听觉感知。

等响曲线

人类的可听频率范围(20Hz 到 20000Hz)中,由于听觉对 3 000 Hz 左右的声音较为敏感,该段频率也能造成较大的听觉感知。
等响曲线的横坐标为频率,纵坐标为声压级。在同一条曲线之上,所有频率和声压的组合,都有着一样的响度。
最下方的曲线表示人类能听到的最小的声音响度,即听阈。等响曲线反映了响度听觉的许多特点:

  • 声压级愈高,响度一般也愈高。
  • 响度频率有关,相同声压级的纯音,频率不同,响度也不同。
  • 对于不同频率的纯音,提高声压级带来的响度增长,也有所不同。

A计权

在相同声强下,人耳对不同频率音频有不同的音量感受,因此需要对不同频率的音频进行加权,得到对应的音量,从而模拟耳朵的听觉效果。在声音测量中,我们以分贝(dB) 为单位测量声音的响度。

上图横坐标是频率,纵坐标是响度增益。在几种常用的加权曲线中,A权重曲线对低频部分相比其他计权有着最多的衰减,是最常用加权策略。其函数实现为如下,其中f代表频率

傅里叶变换

傅里叶在1807年提出,任何连续周期信号可以由一组适当的正弦曲线组合而成。任何周期函数,都可以看作是不同振幅,不同相位正弦波的叠加。
以其名称命名的傅里叶变换(Fourier transform),是用于信号在时域(或空域)和频域之间的变换。

录音获得的数据都是时域的,横轴是时间,纵轴是信号强度。
频谱动画要求横轴是频域数据,傅里叶变换可以实现时域信息到频域信息的转变。
计算机处理的是离散傅里叶变换(DFT)快速傅里叶变换(FFT)是快速计算离散傅里叶变换(DFT)或其逆变换的方法,它将DFT的复杂度从O(n²)降低到O(nlogn)。
苹果的Accelerate框架中vDSP部分提供了数字信号处理的函数实现,包含FFT,其使用可参考《Real FFT/IFFT with the Accelerate Framework》
另外关于FFT比较有趣的文章可以看看
《让你永远忘不了的傅里叶变换解析》
《An Interactive Introduction to Fourier Transforms》
《如果看了这篇文章你还不懂傅里叶变换,那就过来掐死我吧》

整体流程

要实现对录制声音的频谱动画,将功能分解为以上几个部分,Recorder实现对音频PCM数据的采集,RealtimeAnalyser对PCM进行频域变换和数据处理,最后由RecordFeedbackView产生反馈动画。

ios音频采集

iOS中实现音频采集的接口有很多,例如AVAudioRecorderAudioQueueAVAudioEngineAudioUnit
本文使用AudioQueue实现录音功能,AudioQueue的录音的过程如下:AudioQueue将麦克风获取的数据填充到AudioQueueBuffer中 ,通过callback函数回调给app,app将AudioQueueBuffer代表的数据消耗后,将AudioQueueBuffer重新入队到AudioQueue中。
使用AudioQueue实现录音的AudioRecorder在github地址:AudioRecorder,简要介绍其中比较关键的部分。

初始化Recoder

- (void)start 
    [[AVAudioSession sharedInstance] setActive:YES error:nil];
    [[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayAndRecord error:nil];
    
    mAqState.mDataFormat.mSampleRate = self.sampleRate;     //采样率, 1s采集的次数
    mAqState.mDataFormat.mFormatID = kAudioFormatLinearPCM; //数据格式 PCM
    mAqState.mDataFormat.mBitsPerChannel = AUDIO_BIT_LEN;      //在一个数据帧中,每个通道的样本数据的位数。
    mAqState.mDataFormat.mChannelsPerFrame = 1;     //每帧数据通道数(左右声道)
    mAqState.mDataFormat.mFramesPerPacket = 1;      //每包数据帧数
    mAqState.mDataFormat.mBytesPerFrame = (mAqState.mDataFormat.mBitsPerChannel / 8) * mAqState.mDataFormat.mChannelsPerFrame;
    mAqState.mDataFormat.mBytesPerPacket = mAqState.mDataFormat.mBytesPerFrame * mAqState.mDataFormat.mFramesPerPacket;
    mAqState.mDataFormat.mFormatFlags = kLinearPCMFormatFlagIsSignedInteger | kLinearPCMFormatFlagIsPacked;
//  上面使用的格式是signed integer类型,后面在做fft计算的时候需要转换成float类型,如果直接使用Float就少了这个过程。
//    mAqState.mDataFormat.mFormatFlags = kLinearPCMFormatFlagIsFloat | kLinearPCMFormatFlagIsPacked ;
    
    UInt32 frameCount = self.fftSize;
    mAqState.bufferByteSize = frameCount * (mAqState.mDataFormat.mBitsPerChannel / 8);
    AudioQueueNewInput(&mAqState.mDataFormat, HandleInputBuffer, (__bridge void *)(self), NULL, kCFRunLoopCommonModes, 0, &mAqState.mQueue);

    UInt32 channels = 0;
    UInt32 channelsSize = 0;
    AudioChannelLayout channelLayout;
    UInt32 layoutSize = sizeof(channelLayout);
    AudioQueueGetProperty(mAqState.mQueue, kAudioQueueDeviceProperty_NumberChannels, &channels, &channelsSize);
    AudioQueueGetProperty(mAqState.mQueue, kAudioQueueProperty_ChannelLayout, &channelLayout, &layoutSize);
    
    for (int i = 0; i < kNumberBuffers; i++) 
        AudioQueueAllocateBuffer(mAqState.mQueue, mAqState.bufferByteSize, &mAqState.mBuffers[i]);
        AudioQueueEnqueueBuffer(mAqState.mQueue, mAqState.mBuffers[i], 0, NULL);
    
    
    mAqState.mIsRunning = 1;
    OSStatus ret = AudioQueueStart(mAqState.mQueue, NULL);

上面是AudioQueue的启动代码,
mDataFormat设置了录音的数据格式,其中mFormatID:是音频格式,这里使用LinearPCM
另外还有采样率(SampleRate)位深(mBitsPerChannel)每帧的声道数(mChannelsPerFrame)packet帧数(mFramesPerPacket)每帧的字节数(mBytesPerFrame)Packet字节数(mBytesPerPacket)数值格式(mFormatFlags)Int or Float等参数的设置,其中Packet、Frame、Sample的关系如下图:

不过本文使用的是单声道,每个packet里面只有一个frame;

数据回调

static void HandleInputBuffer(void *inUserData,
                              AudioQueueRef inAudioQueue,
                              AudioQueueBufferRef inBuffer,
                              const AudioTimeStamp *inStartTime,
                              UInt32 inNumPackets,
                              const AudioStreamPacketDescription *inPacketDesc) 
    AudioRecorder *recorder = (__bridge AudioRecorder *)inUserData;
    if (recorder == nil) 
        NSLog(@"recorder is dealloc");
        return;
    

    NSTimeInterval recordTime = inStartTime->mSampleTime / recorder->mAqState.mDataFormat.mSampleRate;
    if (inNumPackets > 0) 
        [recorder outputPcmBuffer:inBuffer recordTime:recordTime];
    

    if (recorder->mAqState.mIsRunning) 
        AudioQueueEnqueueBuffer(recorder->mAqState.mQueue, inBuffer, 0, NULL);
    


- (void)outputPcmBuffer:(AudioQueueBufferRef)buffer recordTime:(NSTimeInterval)recordTime 
    int length = buffer->mAudioDataByteSize / mAqState.mDataFormat.mBytesPerFrame;
    NSData *data = [NSData dataWithBytes:buffer->mAudioData length:buffer->mAudioDataByteSize];
    
    // 将录制的PCM数据写文件
    if (self.outputSteam) 
        [self.outputSteam write:(uint8_t *)buffer->mAudioData maxLength:buffer->mAudioDataByteSize];
    

    // 交给分析模块
    [self.analyzer onRecievePcmData:data frameCount:length];

屏幕绘制的频率是16ms一帧,动画需要16ms更新一下动画数据,我们使用的SampleRate使用的是16000,所以我们将AudioQueueBuffer对应的FrameCount设置为1024;
当录制的数据填满一个AudioQueueBuffer时,会通过AudioQueueNewInput函数传入的HandleInputBuffer函数将PCM数据回调给RealtimeAnalyser,所以基本每次刷新屏幕都可以获取到最新的频域数据。

以上是Recorder部分的全部介绍,这部分总体上比较简单,这里的难点部分主要在RealtimeAnalyser这部分。

RealtimeAnalyser

RealtimeAnalyser的实现我放到了github上,可以点击查看。

数值转换

- (void)onRecievePcmData:(NSData *)rawData frameCount:(UInt32)frameCount 
    __weak typeof(self) weakSelf = self;
    dispatch_async(_processQueue, ^
        __strong typeof(weakSelf) strongSelf = weakSelf;

        float fft_data[frameCount];
        short *pcmbuffer = (short *)rawData.bytes;

        vDSP_vflt16(pcmbuffer, 1, fft_data, 1, frameCount);
        float scalar = 1.0 / (1 << (AUDIO_BIT_LEN - 1));
        vDSP_vsmul(fft_data, 1, &scalar, fft_data, 1, frameCount);

        [strongSelf writeInput:fft_data audioFrameCount:frameCount];
    );

由于我这里采集的数据格式是kLinearPCMFormatFlagIsSignedInteger,但是vDSP中处理的数据都是float类型的,所以这里需要对PCM数据的数值格式做转换,直接使用vDSP_vsmul将数值乘以scalar(将数值除以最大值)从而变换为float类型,如果直接使用kLinearPCMFormatFlagIsFloat可以减少这一步。

环形缓冲区

录音数据的产生和频域动画的消耗是一个生产-消费模型,中间需要加一个缓冲区进行数据的缓冲。
这里我使用的是环形缓冲区进行数据的缓存,其模型如下图:

当产生新的录音数据时Write指针前进,当对时域信息进行FFT消耗数据时Read指针也向前移动,达缓冲区的尾部时指针回到缓冲区的起始位置。write和read的实现代码如下

- (void)writeInput:(float *)rawData audioFrameCount:(UInt32)framesToProcess 
    _shouldDoFFTAnalysis = NO;

    float *dest = _inputBuffer + _circleWriteIndex;
    float *source = rawData;
    // Then save the result in the _inputBuffer at the appropriate place.
    if (_circleWriteIndex + framesToProcess > InputBufferSize) 
        int length = InputBufferSize - _circleWriteIndex;
        memcpy(dest, source, sizeof(float) * length);
        dest = _inputBuffer;
        source = rawData + length;
        length = framesToProcess - length;
        memcpy(dest, source, sizeof(float) * length);
        _circleWriteIndex = length;
     else 
        memcpy(dest, source, sizeof(float) * framesToProcess);
        _circleWriteIndex += framesToProcess;
        if (_circleWriteIndex == InputBufferSize) 
            _circleWriteIndex = 0;
        
    
    _bufferLength += framesToProcess;
    // A new render quantum has been processed so we should do the FFT analysis again.
    _shouldDoFFTAnalysis = YES;


- (void)readBufferWithSize:(int)fftSize tempP:(float *)tempP 
    float *inputBuffer = _inputBuffer;
    UInt32 tailLength = InputBufferSize - _circleReadIndex;
    
    if (tailLength < fftSize) 
        memcpy(tempP, inputBuffer + _circleReadIndex, sizeof(float) * (tailLength));
        memcpy(tempP + tailLength, inputBuffer, sizeof(float) * (fftSize - tailLength));
        _circleReadIndex = fftSize - tailLength;
     else 
        memcpy(tempP, inputBuffer + _circleReadIndex, sizeof(float) * fftSize);
        _circleReadIndex += fftSize;
    
    // 减去被消耗缓冲
    _bufferLength -= fftSize;

FFT

这里我们使用的是Accelerate框架中的FFTSetup来实现FFT

1、创建fftSetup

根据vDSP文档,首先需要定义一个FFT权重数组(fftSetup),它可以在多次FFT中重复使用和提升FFT性能。

_fftSize = fftSize;
_log2FFTSize = static_cast<unsigned>(log2(_fftSize));
_fftSetup = vDSP_create_fftsetup(_log2FFTSize, FFT_RADIX2);

2、读取时域信号

从环形缓冲区读出需要处理的时域信号数据

 // Take the previous fftSize values from the input buffer and copy into the temporary buffer.
float *tempP = (float *)malloc(fftSize * sizeof(float));
[self readBufferWithSize:fftSize tempP:tempP];

3、加汉宁窗

需要对时域信号加汉宁窗,汉宁窗的创建:

_hannwindow = (Float32 *)malloc(_fftSize * sizeof(Float32));
vDSP_hann_window(_hannwindow, (vDSP_Length)(_fftSize), vDSP_HANN_NORM);

应用:

// Window the input samples.
vDSP_vmul(tempP, 1, _hannwindow, 1, tempP, 1, fftSize);

加窗主要是为了使信号似乎更好地满足FFT处理的周期性要求,减少泄漏。
这是因为每次FFT只能对有限长度的时域信号进行处理,所以需要对时域信号进行截断,但是可能大部分情况下对周期信号进行的都是非周期性截断(不是周期的整数倍),使得截断后的信号出现泄露。
![在这里插入图片描述](https://img-blog.csdnimg.cn/img_convert/a93a0ebf8b5b2cd9e14dbfc9a95be0ac.jpeg=x200

参考:《怎样用通俗易懂的方式解释窗函数?》《什么是泄漏?》

4、转换为复数

vDSP中的离散傅立叶变换函数为了节省内存,提供了一种独特的数据格式,需要将实数转换为复数形式,既是输入也是输出,后面会对这里的原因作出说明。参考《UsingFourierTransforms》

int halfSize = fftSize / 2;
float value = 0;
vDSP_vfill(&value, _frame.imagp, 1, fftSize / 2);
vDSP_vfill(&value, _frame.realp, 1, fftSize / 2);
vDSP_ctoz(reinterpret_cast<const DSPComplex *>(tempP), 2, &_frame, 1, halfSize);
// 释放内存避免泄露
free(tempP);

以下面为例,这里将8个时域信号数据强制转换为DSPComplex格式的数组,然后通过vDSP_ctoz将其转换为DSPSplitComplex类型的数据。

5、执行FFT

// Perform the FFT via Accelerate
// Use FFT forward for standard PCM audio
vDSP_fft_zrip(_fftSetup, &_frame, 1, _log2FFTSize, FFT_FORWARD);

通过上面的vDSP_fft_zrip进行FFT计算,这里展开讲一下FFT的输入和输出

实信号的频谱是对称的,所以N位样本数据(N/2位复数)进行FFT计算会得到N/2+1位复数结果。

以上面的信号为例,8个实数换成4个复数后进行FFT计算,产生5个复数:

其中第一个复数是直流分量(DC),最后一个复数是Nyquist频率值(NY),它们的虚部都是0,所以可以将NY的值放到DC中的虚部。

这样就可以使得输入和输出的数据共用同一内存,从而节省内存空间。

6、结果缩放

R F i m p = 2 ∗ R F m a t h RF_imp = 2*RF_math RFimp=2RFmath

vDSP为了最佳执行速度并不严格遵循傅立叶变换公式,我们必须相应地对得到的结果进行缩放二分之一。

7、幅值变换

假设原始信号的峰值为A,那么FFT的结果的每个点(除了第一个点直流分量之外)的模值就是A的 N 2 \\fracN2 2N倍。
第一个点就是直流分量,它的模值就是直流分量的N倍。
这是因为傅里叶级数对应时域幅值,其中已经包含了 1 N \\frac1N N1项,而FFT变换中没有该系数,因此,进行FFT变换后,需除以 N 2 \\fracN2 2N才能与时域对上。
简单来说就是采样求和,加了N次,平均值(直流分量)就得除以N,然后每个频点的“能量”分到了两个共轭的频域参数上,所以是除以N/2

    // Blow away the packed nyquist component.
    _frame.imagp[0] = 0;

    // Normalize so than an input sine wave at 0dBfs registers as 0dBfs (undo FFT scaling factor).
    //    https://blog.csdn.net/seekyong/article/details/104434128
    //    https://zhuanlan.zhihu.com/p/137433994   当输入样点数据为复数时除以N
    float magnitudeScale = 2.0 / fftSize;

    // To provide the best possible execution speeds, the vDSP library's functions don't always adhere strictly
    // to textbook formulas for Fourier transforms, and must be scaled accordingly.
    // (See https://developer.apple.com/library/archive/documentation/Performance/Conceptual/vDSP_Programming_Guide/UsingFourierTransforms/UsingFourierTransforms.html#//apple_ref/doc/uid/TP40005147-CH3-SW5)
    // In the case of a Real forward Transform like above: RFimp = RFmath * 2 so we need to divide the output
    // by 2 to get the correct value.
    //http://pkmital.com/home/2011/04/14/real-fftifft-with-the-accelerate-framework/
    magnitudeScale = magnitudeScale / 2;

    vDSP_vsmul(_frame.realp, 1, &magnitudeScale, _frame.realp, 1, halfSize);
    vDSP_vsmul(_frame.imagp, 1, &magnitudeScale, _frame.imagp, 1, halfSize);

    vDSP_vfill(&value, _amplitudes, 1, halfSize);

    //Take the absolute value of the output to get in range of 0 to 1
    vDSP_zvabs(&_frame, 1, _amplitudes, 1, halfSize);

其中的_amplitudes就是将最终出来的频域数据,对数据进行显示如图

我们录音使用的采样率是16000最大频率为8000HZ,对1024个采样点FFT后产生512个频率点的数据,则每个点代表的频率带宽为8000 / 512 = 15.6hz,每个频率点就是其在各自频率上的振幅。

本文参考
https://zhuanlan.zhihu.com/p/137433994
https://blog.csdn.net/seekyong/article/details/104434128
http://pkmital.com/home/2011/04/14/real-fftifft-with-the-accelerate-framework/
https://developer.apple.com/library/archive/documentation/Performance/Conceptual/vDSP_Programming_Guide/UsingFourierTransforms/UsingFourierTransforms.html#//apple_ref/doc/uid/TP40005147-CH3-SW5

wav音频文件解析读取 定点转浮点分析 幅值提取(C语言实现)

引言

在之前的研究中,实现了arm平台C语言对FFT的频谱分析以及失真度测试

从Matlab平台进行FFT到ARM平台C语言FFT频谱分析

从Matlab谐波失真仿真到C语言谐波失真应用

上述文章分析通过sine生成的信号,实际工作中需要解析外部传入的音频文件,然后再进行fft等操作

音频编码

音频编码基本原理

音频信号的冗余信息

数字音频信号如果不加压缩地直接进行传送,将会占用极大的带宽。例如,一套双声道数字音频若取样频率为 44.1KHz,每样值按 16bit 量化,则其码率为:

2 x 44.1 kHz x 16 bit = 1.411 Mbit/s

如此大的带宽将给信号的传输和处理都带来许多困难,因此必须采取音频压缩技术对音频数据进行处理,才能有效地传输音频数据。

数字音频压缩编码在保证信号在听觉方面不产生失真的前提下,对音频数据信号进行尽可能大的压缩。数字音频压缩编码采取去除声音信号中冗余成分的方法来实现。所谓冗余成分指的是音频中不能被人耳感知到的信号,它们对确定声音的音色,音调等信息没有任何的帮助。

冗余信号包含人耳听觉范围外的音频信号以及被掩蔽掉的音频信号等。例如,人耳所能察觉的声音信号的频率范围为 20Hz~20KHz,除此之外的其它频率人耳无法察觉,都可视为冗余信号。此外,根据人耳听觉的生理和心理声学现象,当一个强音信号与一个弱音信号同时存在时,弱音信号将被强音信号所掩蔽而听不见,这样弱音信号就可以视为冗余信号而不用传送。这就是人耳听觉的掩蔽效应,主要表现在频谱掩蔽效应和时域掩蔽效应。

频谱掩蔽效应

一个频率的声音能量小于某个阈值之后,人耳就会听不到,这个阈值称为最小可闻阈。当有另外能量较大的声音出现的时候,该声音频率附近的阈值会提高很多,即所谓的掩蔽效应。如图所示:

由图中我们可以看出人耳对 2KHz~5KHz 的声音最敏感,而对频率太低或太高的声音信号都很迟钝,当有一个频率为 0.2K Hz、强度为 60dB 的声音出现时,其附近的阈值提高了很多。由图中我们可以看出在 0.1KHz 以下、1KHz 以上的部分,由于离 0.2KHz 强信号较远,不受 0.2KHz 强信号影响,阈值不受影响;而在 0.1KHz~1KHz 范围,由于 0.2KHz 强音的出现,阈值有较大的提升,人耳在此范围所能感觉到的最小声音强度大幅提升。如果 0.1KHz~1KHz 范围内的声音信号的强度在被提升的阈值曲线之下,由于它被 0.2KHz 强音信号所掩蔽,那么此时我们人耳只能听到 0.2KHz 的强音信号而根本听不见其它弱信号,这些与 0.2KHz 强音信号同时存在的弱音信号就可视为冗余信号而不必传送。

时域掩蔽效应

当强音信号和弱音信号同时出现时,还存在时域掩蔽效应。即两者发生时间很接近的时候,也会发生掩蔽效应。时域掩蔽过程曲线如图所示,分为前掩蔽、同时掩蔽和后掩蔽三部分。

由图我们可以看出,时域掩蔽效应可以分成三种:前掩蔽,同时掩蔽,后掩蔽。前掩蔽是指人耳在听到强信号之前的短暂时间内,已经存在的弱信号会被掩蔽而听不到。同时掩蔽是指当强信号与弱信号同时存在时,弱信号会被强信号所掩蔽而听不到。后掩蔽是指当强信号消失后,需经过较长的一段时间才能重新听见弱信号,称为后掩蔽。这些被掩蔽的弱信号即可视为冗余信号。

压缩编码方法

当前数字音频编码领域存在着不同的编码方案和实现方式,但基本的编码思路大同小异,如图所示。

对每一个音频声道中的音频采样信号,首先都要将它们映射到频域中,这种时域到频域的映射可通过子带滤波器实现。每个声道中的音频采样块首先要根据心理声学模型来计算掩蔽门限值,然后由计算出的掩蔽门限值决定从公共比特池中分配给该声道的不同频率域中多少比特数,接着进行量化以及编码工作,最后将控制参数及辅助数据加入数据之中,产生编码后的数据流。

音频采样

真实世界中的声音都是连续的,因为声音是模拟信号。但是在计算机中存储的信息都是数字信号。所以在将声音存储到计算机之前,就必须要进行声音的数字化,转换成计算机能够存储的形式。

模拟信号转换为数字信号,一种比较通用的方法就是进行等间隔采样。根据奈奎斯特定理,采样频率至少为信号频率的 2 倍,才能无失真的保存原有的音频信号。因此采样频率的高低决定了数字信号的保真度,自然是越高越好。比如,一个周期为 1ms 的正弦信号,采两个点和采 100 个点的信号在还原成模拟信号的时候,肯定是采 100 个点信号的还原效果更好。

音频量化

量化就是把经过采样得到的瞬时值将其幅度离散,即用一组规定的电平,把瞬时采样值用最接近的电平值来表示。所谓的音频量化,就是用二进制数据来表示电平的大小。一般采用8位(256级)或者16位(65536级)的数据来表示。

常见的量化器有:均匀量化器,对数量化器,非均匀量化器。量化过程追求的目标是:最小化量化误差,并尽量减低量化器的复杂度(这二者本身就是一个矛盾)。

均匀量化器:最简单,性能最差,仅适应于电话语音。

对数量化器:比均匀量化器复杂,也容易实现,性能比均匀量化器好。

Non-uniform量化器:根据信号的分布情况,来设计量化器。信号密集的地方进行细致的量化,稀疏的地方进行粗略量化。

语音 / 音频编码算法
语音 / 音频编码算法主要有以下 6 种:

  1. 波形编码
    波形编码是最简单也是应用最早的语音编码方法。最基本的一种就是 PCM 编码,如 G.711 建议中的 A 律或 μ 律。APCM、DPCM 和 ADPCM 也属于波形编码的范畴,使用这些技术的标准有 G.721、G.726、G.727 等。波形编码具有实施简单、性能优良的特点,不足是编码带宽往往很难再进一步下降。
  2. 预测编码
    语音信号是非平稳信号,但在短时间段内(一般是30ms)具有平稳信号的特点,因而对语音信号幅度进行预测编码是一种很自然的做法。最简单的预测是相邻两个样点间求差分,编码差分信号,如G.721。但更广为应用的是语音信号的线性预测编码(LPC)。几乎所有的基于语音信号产生的全极点模型的参数编码器都要用到 LPC, 如 G.728、G.729、G.723.1。
  3. 参数编码
    参数编码建立在人类语音产生的全极点模型的理论上,参数编码器传输的编码参数也就是全极点模型的参数——基频、线谱对、增益。对语音来说,参数编码器的编码效率最高,但对音频信号,参数编码器就不太合适。典型的参数编码器有 LPC- 10、LPC-10E,另外,G.729、G.723.1 以及 CELP(FS- 1016)等码激励线性预测声码器都离不开参数编码。
  4. 变换编码
    一般认为变换编码在语音信号中作用不是很大,但在音频信号中它却是主要的压缩方法。比如,MPEG 伴音压缩算法(含著名的 MP3) 用到 FFT、MDCT 变换,AC-3 杜比立体声也用到 MDCT,G.722.1 建议中采用的 MLT 变换。在近年来出现的低速率语音编码算法中,STC(正弦变换编码)和 WI(波形插值)占有重要的位置,小波变换和 Gabor 变换在其中就有用武之地。
  5. 子带编码
    子带编码一般是同波形编码结合使用,如 G.722 使用的是 SB-ADPCM 技术。但子带的划分更多是对频域系数的划分(这可以更好地利用低频带比高频带感觉更重要的特点),故子带编码中,往往先要应用某种变换方法得到频域系数,在 G.722.1 中使用 MLT 变换,系数划分为 16 个子带;MPEG 伴音中用 FFT 或 MDCT 变换,划分的子带多达 32 个。
  6. 统计编码
    统计编码在图像编码中大量应用,但在语音编码中出于对编码器整体性能的考虑(变长编码易引起误码扩散),很少使用。对存在统计冗余的信号来说,统计编码确实可以大大提高编码的效率,所以,近年来出现的音频编码算法中,统计编码又重新得到了重视。MPEG 伴音和 G.722.1 建议中采纳了哈夫曼变长编码。

PCM格式

PCM (Pulse Code Modulation) 也被称为脉冲编码调制。PCM 音频数据是未经压缩的音频采样数据裸流,它是由模拟信号经过采样、量化、编码转换成的标准的数字音频数据。

PCM 音频数据的存储

如果是单声道的音频文件,采样数据按时间的先后顺序依次存入(有的时候也会采用 LRLRLR 方式存储,只是另一个声道的数据为 0),如果是双声道的话通常按照 LRLRLR 的方式存储,存储的时候还和机器的大小端有关。大端模式如下图所示:

PCM 音频数据是未经压缩的数据,所以通常都比较大,常见的 MP3 格式都是经过压缩的,128Kbps 的 MP3 压缩率可以达到 1:11

PCM 音频数据的参数

一般我们描述 PCM 音频数据的参数的时候有如下描述方式:

44100HZ 16bit stereo: 每秒钟有 44100 次采样, 采样数据用 16 位(2 字节)记录, 双声道(立体声)
22050HZ 8bit  mono: 每秒钟有 22050 次采样, 采样数据用 8 位(1 字节)记录, 单声道
48000HZ 32bit 51ch: 每秒钟有 48000 次采样, 采样数据用 32 位(4 字节浮点型)记录, 5.1 声道

44100Hz 指的是采样率,它的意思是每秒取样 44100 次。采样率越大,存储数字音频所占的空间就越大。

16bit 指的是采样精度,意思是原始模拟信号被采样后,每一个采样点在计算机中用 16 位(两个字节)来表示。采样精度越高越能精细地表示模拟信号的差异。

Stereo 指的是声道数,也即采样时用到的麦克风的数量,麦克风越多就越能还原真实的采样环境(当然麦克风的放置位置也是有规定的)。

一般来说 PCM 数据中的波形幅值越大,代表音量越大。

WAV格式

WAV 是 Microsoft 和 IBM 为 PC 开发的一种声音文件格式,它符合 RIFF(Resource Interchange File Format)文件规范,用于保存 Windows 平台的音频信息资源,被 Windows 平台及其应用程序所广泛支持。WAVE 文件通常只是一个具有单个 “WAVE” 块的 RIFF 文件,该块由两个子块(”fmt” 子数据块和 ”data” 子数据块),它的格式如下图所示:

WAV 格式定义

该格式的实质就是在 PCM 文件的前面加了一个文件头,每个字段的的含义如下:

typedef struct 
    char          ChunkID[4]; //内容为"RIFF"
    unsigned long ChunkSize;  //存储文件的字节数(不包含ChunkID和ChunkSize这8个字节)
    char          Format[4];  //内容为"WAVE“
 WAVE_HEADER;

typedef struct 
   char           Subchunk1ID[4]; //内容为"fmt"
   unsigned long  Subchunk1Size;  //存储该子块的字节数(不含前面的Subchunk1ID和Subchunk1Size这8个字节)
   unsigned short AudioFormat;    //存储音频文件的编码格式,例如若为PCM则其存储值为1。
   unsigned short NumChannels;    //声道数,单声道(Mono)值为1,双声道(Stereo)值为2,等等
   unsigned long  SampleRate;     //采样率,如8k,44.1k等
   unsigned long  ByteRate;       //每秒存储的bit数,其值 = SampleRate * NumChannels * BitsPerSample / 8
   unsigned short BlockAlign;     //块对齐大小,其值 = NumChannels * BitsPerSample / 8
   unsigned short BitsPerSample;  //每个采样点的bit数,一般为8,16,32等。
 WAVE_FMT;

typedef struct 
   char          Subchunk2ID[4]; //内容为“data”
   unsigned long Subchunk2Size;  //接下来的正式的数据部分的字节数,其值 = NumSamples * NumChannels * BitsPerSample / 8
 WAVE_DATA;

WAV 文件头解析

这里是一个 WAVE 文件的开头 72 字节,字节显示为十六进制数字:

52 49 46 46 | 24 08 00 00 | 57 41 56 45
66 6d 74 20 | 10 00 00 00 | 01 00 02 00 
22 56 00 00 | 88 58 01 00 | 04 00 10 00
64 61 74 61 | 00 08 00 00 | 00 00 00 00 
24 17 1E F3 | 3C 13 3C 14 | 16 F9 18 F9
34 E7 23 A6 | 3C F2 24 F2 | 11 CE 1A 0D

字段解析如下图:

WAV解析

定点数转换浮点数

分析完原理之后,对wav文件进行解析,本质也是提取出来wav文件中的数据部分,然后把量化后的定点数变成浮点数。

matlab比较方便,可以直接解析后,然后通过图片展示,可以明确的得知第一个点的幅值为-0.0492554

难点就来到了在C语言上如何对定点数进行分析

我们要分析的wav文件,采样率48000hz,24位,单声道

通过二进制查看软件我们可以看到wav文件的数据部分

因为此文件的位深,也就是量化位24位,即去除wav的44byte头部之后,数据部分,3 byte一个数据。

但是这个24位数据不能直接进行转换,例如第一个数据 00 9F F9

得出来的是40953,对这个量化数进行还原到实际幅值便是,40953 / pow(2, 23) = 0.00488197803497314453125

因为我知道正确答案,所以可以直接告诉大家,结果是不正确的

这里有一个错误,就是我们的数据,不是00 9F F9这样存储的,实际数据应该是 F9 9F 00

确定实际的数据表示后,下一步是要明白在这24位的数据中,因为是定点数,所以第24位是符号位,只有23位表示实际的数据

问题现在变成了首先要确定数据是正负,才能进行数值的转换,正数大家都知道,就直接介绍负数吧

还是我们的这个数据F9 9F 00,1111 1001 1001 1111 0000 0000,读取到24位是1,这是一个负数,对于数据111 1001 1001 1111 0000 0000,首先要做的就是-1,然后就是取反,接着是转换成十进制,最后就是添加上负号。

-1
111 1001 1001 1111 0000 0000 - 1 = 111 1001 1001 1110 1111 1110

取反
~111 1001 1001 1110 1111 1110 = 000 0110 0110 0001 0000 0001

得出来的是418049,对这个量化数加上符号位,然后进行还原到实际幅值便是,-418049/ pow(2, 23) = -0.04983532428741455078125

与实际结果一致

代码

/*
 * wav文件解析
 */
int parse_wave_file_double(char *filePath, double *wavData)

    char head[44];
    FILE *fp;
    int read_len;

    if ((fp = fopen(filePath, "rb")) == NULL)          // 打开文件
        perror("Open file failed\\n");
        return -1;
    
    read_len = fread(head, sizeof(char), 44, fp);       // 读取数据到bufferp
    if (head[20] != 1)                                  // 检查是否PCM格式
        return -1;
    int channel = head[22];
    if (channel != 1)                                   // 检查是否单声道
        return -1;

    int sample = 0;
    sample = head[24] + (head[25] << 8)  + (head[26] << 8*2)  + (head[27] << 8*3);
    if (sample != 48000)                                // 检查采样率是否48000
        return -1;

    int bytesPerSecond = 0;                             // 平均每秒字节数
    bytesPerSecond = head[28] + (head[29] << 8)  + (head[30] << 8*2)  + (head[31] << 8*3);
    
    int bits = head[34];                                // 采样位数
    if (bits != 16 && bits != 24)                       // 检查位数是否为16或24
        return -1;
    
    int dataNum = 0;
    dataNum = head[40] + (head[41] << 8)  + (head[42] << 8*2)  + (head[43] << 8*3);
    if (dataNum > (bytesPerSecond * 4))                 // 数据长度大于4s
        return -1;
    
    char data[dataNum];
    fread(data, sizeof(char), dataNum, fp);

    int bytes = 0;
    int sampleNum = 0;

    bytes = bits / 8;                               // 若采样位数bits为24位,则Bytes = 3;
    sampleNum = dataNum / bytes;                    // 数据的总大小 / 3 = 时域中采样点的个数
    
    int j;
    int pow_2_23 = pow(2, 23);
    int pow_2_15 = pow(2, 15);
    for (int i = 0; i < sampleNum; i++ ) 
        char temp[4] =  0, 0, 0, 0 ;
        double nTemp = 0.0;
        int bufftemp = 0;
        /*
         * data从bytes * i开始 复制bytes个值到temp 从temp的4-bytes位置开始存储
         */
        for(j = 0; j < bytes; j++)                     // 24位音频数据,3个字节1个采样点数据
            temp[j] = data[bytes * i + j];              // 靠右存储 例如原数据            
        

        if(bytes == 3) 

            bufftemp = 0;

            bufftemp = temp[0] + (temp[1] << 8)  + (temp[2] << 8 * 2);
            if( (bufftemp | 0x7fffff) == 0xffffff )     // 若bufftemp = F99F00 = 1111 1001 1001 1111 0000 0000,& 0111 1111 1111 1111 1111 1111 若结果为ffffffff判断首位为1,否则为0
                nTemp = ( ~( (bufftemp - 0x01) | 0xff000000 ) ) / (pow_2_23 * (-1.0));
             else if( (bufftemp | 0x7fffff) == 0x7fffff ) 
                nTemp = bufftemp / (pow_2_23 * 1.0);
            
         
        // else if(bytes == 2) 
        //     nTemp = temp[0] + (temp[1] << 8);
        //     if( (bufftemp | 0x00007fff) == 0x0000ffff )  
        //         bufftemp = bufftemp - 0x01;
        //         bufftemp = bufftemp | 0xffff0000;
        //         bufftemp = ~bufftemp;
        //         nTemp = bufftemp / (pow_2_15 * (-1.0));
        //      else if( (bufftemp | 0x00007fff) == 0x00007fff ) 
        //         nTemp = bufftemp / (pow_2_15 * 1.0);
        //     
        // 

        wavData[i] = nTemp;
    
    fclose(fp);
    
    return 0;

以上是关于音频频谱动画的原理与实现的主要内容,如果未能解决你的问题,请参考以下文章

Qt之调用FFTW3实现音频频谱(原理)

Qt之调用FFTW3实现音频频谱(实现)

Python音频处理——信号,波形与频谱

在 Java 中使用 FFT 算法进行音频频谱分析

H5录音音频可视化-实时波形频谱绘制频率直方图

在 C/C++ 中使用 JACK 和 fftw 的音频频谱