目录
考虑到Wav文件播放、文件合并、文件分隔、文件格式转换等都要求对文件内部结构要有所了解,对NAudio中是如何组织管理文件内容要清晰掌握,本篇将对这两者的对应关系做深入分析,下篇将基于此,实现音频分割功能。
重要声明
Wav文件结构描述主要参考以下作者的文章:
wav文件格式分析与详解:https://www.cnblogs.com/ranson7zop/p/7657874.html
WAV文件格式详解:https://www.jianshu.com/p/947528f3dff8
对作者表示感谢。
WAV文件是在PC机平台上很常见的、最经典的多媒体音频文件,最早于1991年8月出现在Windows 3.1操作系统上,文件扩展名为WAV,是WaveFom的简写,也称为波形文件,可直接存储声音波形,还原的波形曲线十分逼真。WAV文件格式简称WAV格式是一种存储声音波形的数字音频格式,是由微软公司和IBM联合设计的,经过了多次修订,可用于Windows,Macintosh,Linix等多种操作系统,详述如下。
波形文件的基础知识
波形文件的存储过程
声源发出的声波通过话筒被转换成连续变化的电信号,经过放大、抗混叠滤波后,按固定的频率进行采样,每个样本是在一个采样周期内检测到的电信号幅度值;接下来将其由模拟电信号量化为由二进制数表示的积分值;最后编码并存储为音频流数据。有的应用为了节省存储空间,存储前,还要对采样数据先进行压缩。
与声音有关的三个参数
1、采样频率
又称取样频率。是单位时间内的采样次数,决定了数字化音频的质量。采样频率越高,数字化音频的质量越好,还原的波形越完整,播放的声音越真实,当然所占的资源也越多。根据奎特采样定理,要从采样中完全恢复原始信号的波形,采样频率要高于声音中最高频率的两倍。人耳可听到的声音的频率范围是在16Hz-20kHz之间。因此,要将听到的原声音真实地还原出来,采样频率必须大于4 0k H z 。常用的采样频率有8 k H z 、1 1 . 02 5 k H z 、22.05kHz、44.1kHz、48kHz等几种。22.05KHz相当于普通FM广播的音质,44.1KHz理论上可达到CD的音质。对于高于48KHz的采样频率人耳很难分辨,没有实际意义。
2、采样位数
也叫量化位数(单位:比特),是存储每个采样值所用的二进制位数。采样值反应了声音的波动状态。采样位数决定了量化精度。采样位数越长,量化的精度就越高,还原的波形曲线越真实,产生的量化噪声越小,回放的效果就越逼真。常用的量化位数有4、8、12、16、24。量化位数与声卡的位数和编码有关。如果采用PCM编码同时使用8 位声卡, 可将音频信号幅度从上限到下限化分成256个音量等级,取值范围为0-255;使用16位声卡,可将音频信号幅度划分成了64K个音量等级,取值范围为-32768至32767。
3、声道数
是使用的声音通道的个数,也是采样时所产生的声音波形的个数。播放声音时,单声道的WAV一般使用一个喇叭发声,立体声的WAV可以使两个喇叭发声。记录声音时,单声道,每次产生一个波形的数据,双声道,每次产生两个波形的数据,所占的存储空间增加一倍。
WAV文件的编码
编码包括了两方面内容,一是按一定格式存储数据,二是采用一定的算法压缩数据。WAV格式对音频流的编码没有硬性规定,支持非压缩的PCM(Puls Code Modulation)脉冲编码调制格式,还支持压缩型的微软自适应分脉冲编码调制Microsoft ADPCM(Adaptive Differential Puls Code Modulation)、国际电报联盟(International Telegraph Union)制定的语音压缩标准ITUG.711 a-law、ITU G.711-law、IMA ADPCM、ITU G.723 ADPCM (Yamaha)、GSM 6.10、ITU G.721 ADPCM编码和其它压缩算法。MP3编码同样也可以运用在WAV中,只要安装相应的Decode,就可以播放WAV中的MP3音乐。
文件整体结构
WAV文件遵循RIFF规则,其内容以区块(chunk)为最小单位进行存储。WAV文件一般由3个区块组成:RIFF chunk、Format chunk和Data chunk。另外,文件中还可能包含一些可选的区块,如:Fact chunk、Cue points chunk、Playlist chunk、Associated data list chunk等。
本文将只介绍RIFF chunk、Format chunk和Data chunk。
先用utraedit打开一个实际wav文件。
00000000h: 52 49 46 46 A6 C0 00 00 57 41 56 45 66 6D 74 20 ; RIFF..WAVEfmt
00000010h: 10 00 00 00 01 00 01 00 80 3E 00 00 00 7D 00 00 ; ........€>...}..
00000020h: 02 00 10 00 64 61 74 61 82 C0 00 00 DB FF DB FF ; ....data偫..??
00000030h: DA FF DA FF D9 FF D8 FF D8 FF D7 FF D6 FF D5 FF ; ????????
00000040h: D6 FF D4 FF D4 FF D3 FF D2 FF D2 FF D1 FF D0 FF ; ????????
00000050h: CF FF CF FF CE FF CE FF CC FF CC FF CB FF CA FF ; ????????
00000060h: C9 FF C8 FF C7 FF C6 FF C7 FF AD FF 9B FF 95 FF ; ????????
00000070h: C5 FF F0 FF C8 FF 89 FF 95 FF B5 FF CA FF FA FF ; ????????
00000080h: D7 FF 84 FF 8D FF 97 FF 98 FF D6 FF E6 FF 9F FF ; ????????
从上图可以看出来,典型的文件结构分为3个区块:RIFF区块、fmt区块和data区块。
字节序说明
上图左侧的单词endian
意思是字节序、端序,表示字节的存储顺序。
字节序分为两种:大端模式(big)和小端模式。
- 大端模式,是指数据的低字节保存在内存的高地址中,而数据的高字节,保存在内存的低地址中;
- 小端模式,是指数据的低字节保存在内存的低地址中,而数据的高字节保存在内存的高地址中。
例如如果我们将0x1234abcd写入到以0x0000开始的内存中,则结果为
内存 | big-endian | little-endian |
---|---|---|
0x0000 | 0x12 | 0xcd |
0x0001 | 0x34 | 0xab |
0x0002 | 0xab | 0x34 |
0x0003 | 0xcd | 0x12 |
简单记忆就是小端方式字节和地址高低一致。
RIFF区块
名称 | 移地址 | 字节数 | 端序 | 内容 | 说明 |
---|---|---|---|---|---|
ID | 0x00 | 4Byte | 大端 | ‘RIFF’ (0x52494646) | 以’RIFF’为标识 |
Size | 0x04 | 4Byte | 小端 | fileSize - 8 | 是整个文件的长度减去ID和Size的长度 |
Type | 0x08 | 4Byte | 大端 | ‘WAVE’(0x57415645) | WAVE表示后面需要两个子块:Format区块和Data区块 |
fmt区块(FORMAT区块)
名称 | 偏移地址 | 字节数 | 端序 | 内容 | 说明 |
---|---|---|---|---|---|
ID | 0x00 | 4Byte | 大端 | 'fmt ’ (0x666D7420) | 以’fmt '为标识 |
Size | 0x04 | 4Byte | 小端 | 16 | 表示该区块数据的长度(不包含ID和Size的长度) |
AudioFormat | 0x08 | 2Byte | 小端 | 音频格式 | Data区块存储的音频数据的格式,PCM音频数据的值为1 |
NumChannels | 0x0A | 2Byte | 小端 | 声道数 | 音频数据的声道数,1:单声道,2:双声道 |
SampleRate | 0x0C | 4Byte | 小端 | 采样率 | 音频数据的采样率 |
ByteRate | 0x10 | 4Byte | 小端 | 每秒数据字节数 | = SampleRate * NumChannels * BitsPerSample / 8 |
BlockAlign | 0x14 | 2Byte | 小端 | 数据块对齐 | 每个采样所需的字节数 = NumChannels * BitsPerSample / 8 |
BitsPerSample | 0x16 | 2Byte | 小端 | 采样位数 | 每个采样存储的bit数,8:8bit,16:16bit,32:32bit |
读者可以自己对照着上面的实际wav文件,看看这些参数分别是多少。
DATA区块
名称 | 偏移地址 | 字节数 | 端序 | 内容 | 说明 |
---|---|---|---|---|---|
ID | 0x00 | 4Byte | 大端 | ‘data’ (0x64617461) | 以’data’为标识 |
Size | 0x04 | 4Byte | 小端 | N | 音频数据的长度,N = ByteRate * seconds |
Data | 0x08 | NByte | 小端 | 音频数据 |
下面解释一下PCM数据在WAV文件中的bit位排列方式
PCM数据类型 | 采样1 | 采样2 |
---|---|---|
8Bit 单声道 | 声道0 | 声道0 |
8Bit 双声道 | 声道0 | 声道1 |
16Bit 单声道 | 声道0低位,声道0高位 | 声道0低位,声道0高位 |
16Bit 双声道 | 声道0低位,声道0高位 | 声道1低位,声道1高位 |
NAudio文件数据管理分析
NAudio文件主要由AudioFileReader类来管理,最终的文件数据由WaveFileReader类来管理。
AudioFileReader类
AudioFileReader
的继承关系如下图所示。
构造函数
AudioFileReader
只有一个构造函数,传入声音文件名。
public AudioFileReader(string fileName)
{
lockObject = new object();
FileName = fileName;
CreateReaderStream(fileName);
//......
}
通过CreateReaderStream
函数获得音频流。
private void CreateReaderStream(string fileName)
{
if (fileName.EndsWith(".wav", StringComparison.OrdinalIgnoreCase))
{
readerStream = new WaveFileReader(fileName);
if (readerStream.WaveFormat.Encoding != WaveFormatEncoding.Pcm && readerStream.WaveFormat.Encoding != WaveFormatEncoding.IeeeFloat)
{
readerStream = WaveFormatConversionStream.CreatePcmStream(readerStream);
readerStream = new BlockAlignReductionStream(readerStream);
}
}
else if (fileName.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
{
readerStream = new Mp3FileReader(fileName);
}
else if (fileName.EndsWith(".aiff", StringComparison.OrdinalIgnoreCase) || fileName.EndsWith(".aif", StringComparison.OrdinalIgnoreCase))
{
readerStream = new AiffFileReader(fileName);
}
else
{
// fall back to media foundation reader, see if that can play it
readerStream = new MediaFoundationReader(fileName);
}
}
从以上代码可以看出来,AudioFileReader
支持3种文件格式:wav、mp3和aiff。如果不是这3种扩展名,AudioFileReader
会尝试按照标准的媒体格式尝试读取,使用IMFSourceReader接口实现此功能。
从代码可以看出,对wav
文件,得到的读数据流readerStream
来自WaveFileReader
对象。
属性
AudioFileReader
有5个属性,如下表所示。
属性名 | 数据类型 | 类型 | 功能 |
---|---|---|---|
FileName | string | R | 声音文件名 |
WaveFormat | WaveFormat | R | 数据流的格式信息 |
Length | long | R | 数据流的数据区字节数(每个通道的每采样按4字节), |
Position | long | RW | 数据流的位置 |
Volume | float | RW | 音量,0~1f |
几个属性的代码如下。
public override WaveFormat WaveFormat => sampleChannel.WaveFormat;
public float Volume
{
get { return sampleChannel.Volume; }
set { sampleChannel.Volume = value; }
}
可见这些属性的操作都是通过sampleChannel对象来执行,关于此对象下面详细分析。
读数据
AudioFileReader
本质上只有一个对外方法Read
,代码如下。
public int Read(float[] buffer, int offset, int count)
{
lock (lockObject)
{
return sampleChannel.Read(buffer, offset, count);
}
}
从NAudio用法详解(6)播放过程流程分析 中可知,waveOutEvent
最终调用了AudioFileReader.Read方法实现读取数据,而这个Read最终来自SampleCannel.Read方法,因此后面详细分析SampleCannel
。
下图为WaveOutEvent调用的WaveOutBuffer对象的OnDone方法。
下面首先介绍WaveFileRead
类,然后再详细介绍SampleChannel
。
WaveFileReader类
WaveFileReader类主要实现两大功能,读取声音文件,获得音频格式;读取任意位置音频数据,并可以任意调整当前数据位置。
构造函数
public WaveFileReader(String waveFile) :
this(File.OpenRead(waveFile), true)
{
}
构造函数传入文件名,然后使用File.OpenRead
函数得到stream,再执行下面的构造函数。
public WaveFileReader(Stream inputStream) :
this(inputStream, false)
{
}
最终执行的构造函数如下。
private WaveFileReader(Stream inputStream, bool ownInput)
{
this.waveStream = inputStream;
var chunkReader = new WaveFileChunkReader();
try
{
chunkReader.ReadWaveHeader(inputStream);
waveFormat = chunkReader.WaveFormat;
dataPosition = chunkReader.DataChunkPosition;
dataChunkLength = chunkReader.DataChunkLength;
ExtraChunks = chunkReader.RiffChunks;
}
catch
{
if (ownInput)
{
inputStream.Dispose();
}
throw;
}
Position = 0;
this.ownInput = ownInput;
}
在这个构造函数冲,WaveFileChunkReader
类管理文件流的头部信息,整合成WaveFormat
对象,管理数据区块的位置、长度等等信息。WaveFileReader
保存waveFormat
(文件格式信息)、dataPosition
(数据区块在文件中的位置)、dataChunkLength
(数据区块的长度)。
读取数据
读取数据使用实现的Read函数。
public override int Read(byte[] array, int offset, int count)
{
if (count % waveFormat.BlockAlign != 0)
{
throw new ArgumentException(
$"Must read complete blocks: requested {count}, block align is {WaveFormat.BlockAlign}");
}
lock (lockObject)
{
// sometimes there is more junk at the end of the file past the data chunk
if (Position + count > dataChunkLength)
{
count = (int) (dataChunkLength - Position);
}
return waveStream.Read(array, offset, count);
}
}
本质上是调用了fileStream.Read
函数而已。
SampleChannel类
SampleChannel
类翻译为采集通道,一个声音文件由文件描述信息(RIFF区块和fmt区块)和数据信息(数据区块)组成,而声音又分为单通道、双通道及多通道。本类就是管理通道数据。
SampleChannel
类主要实现3大功能。
- 输入为IWaveProvider类型,转换为ISampleProvider类型,并对外暴露出来。
- 音量调节
- 在读数据过程中,通过MeteringSampleProvider对象,周期性的生成事件,报告最大音量信息。
构造函数
构造函数实现了3个功能。
- 将输入为IWaveProvider类型,转换为ISampleProvider类型;
- 按需要将单声道转换为双声道;
- 初始化两个对象
MeteringSampleProvider
、和VolumeSampleProvider
。
public SampleChannel(IWaveProvider waveProvider, bool forceStereo)
{
ISampleProvider sampleProvider = SampleProviderConverters.ConvertWaveProviderIntoSampleProvider(waveProvider);
if (sampleProvider.WaveFormat.Channels == 1 && forceStereo)
{
sampleProvider = new MonoToStereoSampleProvider(sampleProvider);
}
waveFormat = sampleProvider.WaveFormat;
// let's put the meter before the volume (useful for drawing waveforms)
preVolumeMeter = new MeteringSampleProvider(sampleProvider);
volumeProvider = new VolumeSampleProvider(preVolumeMeter);
}
读数据
上节分析过,waveOutEvent
最终调用了AudioFileReader.Read方法实现读取数据,而这个Read()方法调用了SampleCannel.Read
方法,下面是这个方法的代码。
public int Read(float[] buffer, int offset, int sampleCount)
{
return volumeProvider.Read(buffer, offset, sampleCount);
}
哈哈,令人失望的是,这个Read也不是最终的Read,而是调用了VolumeSampleProvider对象的Read方法。关于VolumeSampleProvider
,本篇不会全面分析,只分析关键的Read方法。
//VolumeSampleProvider.Read
public int Read(float[] buffer, int offset, int sampleCount)
{
int samplesRead = source.Read(buffer, offset, sampleCount);
if (Volume != 1f)
{
for (int n = 0; n < sampleCount; n++)
{
buffer[offset + n] *= Volume;
}
}
return samplesRead;
}
从代码可以看出VolumeSampleProvider.Read
内部又调用的source.Read,而这个Source从SampleChannel
构造函数中可以看出来。
preVolumeMeter = new MeteringSampleProvider(sampleProvider);
volumeProvider = new VolumeSampleProvider(preVolumeMeter);
即,source为preVolumeMeter
,那么相当于调用了preVolumeMeter.Read
,再看MeteringSampleProvider.Read
,代码如下。
//MeteringSampleProvider
public int Read(float[] buffer, int offset, int count)
{
int samplesRead = source.Read(buffer, offset, count);
天哪,又是个source.Read
,这个source是sampleProvider
。从SampleChannel
构造函数中可以看出,这个sampleProvider
对象为实现了ISampleProvider
接口的某个实际类的对象。
ISampleProvider sampleProvider = SampleProviderConverters.ConvertWaveProviderIntoSampleProvider(waveProvider);
继续追这个ConvertWaveProviderIntoSampleProvider
函数,可以发现,最终的Read由waveProvider
来实现,而这个waveProvider
其实就是WaveFileReader
对象。
public static ISampleProvider ConvertWaveProviderIntoSampleProvider(IWaveProvider waveProvider)
{
ISampleProvider sampleProvider;
if (waveProvider.WaveFormat.Encoding == WaveFormatEncoding.Pcm)
{
// go to float
if (waveProvider.WaveFormat.BitsPerSample == 8)
{
sampleProvider = new Pcm8BitToSampleProvider(waveProvider);
}
else if (waveProvider.WaveFormat.BitsPerSample == 16)
{
sampleProvider = new Pcm16BitToSampleProvider(waveProvider);
}
else if (waveProvider.WaveFormat.BitsPerSample == 24)
{
sampleProvider = new Pcm24BitToSampleProvider(waveProvider);
}
else if (waveProvider.WaveFormat.BitsPerSample == 32)
{
sampleProvider = new Pcm32BitToSampleProvider(waveProvider);
}
else
{
throw new InvalidOperationException("Unsupported bit depth");
}
}
else if (waveProvider.WaveFormat.Encoding == WaveFormatEncoding.IeeeFloat)
{
if (waveProvider.WaveFormat.BitsPerSample == 64)
sampleProvider = new WaveToSampleProvider64(waveProvider);
else
sampleProvider = new WaveToSampleProvider(waveProvider);
}
else
{
throw new ArgumentException("Unsupported source encoding");
}
return sampleProvider;
}
读数据的路线
WaveOutEvent类的Read方法路线如下(省略Read()方法)。
AudioFileReader→SampleChanel→VolumeSampleProvider→MeteringSampleProvider→WaveFileReader
音量调节原理
调节音量是通过VolumeSampleProvider对象的Volume属性。
public float Volume
{
get { return volumeProvider.Volume; }
set { volumeProvider.Volume = value; }
}
在VolumeSampleProvider
类的读方法中,对获得的数据的值,直接乘以音量属性即可,是不是很简单!。
//VolumeSampleProvider
public int Read(float[] buffer, int offset, int sampleCount)
{
int samplesRead = source.Read(buffer, offset, sampleCount);
if (Volume != 1f)
{
for (int n = 0; n < sampleCount; n++)
{
buffer[offset + n] *= Volume;
}
}
return samplesRead;
}