65.9K
CodeProject 正在变化。 阅读更多。
Home

如何在.NET中转换(大多数)音频格式

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.99/5 (100投票s)

2012年12月5日

CPOL

59分钟阅读

viewsIcon

519793

使用NAudio在.NET中解码和编码音频文件的综合指南。

引言

音频可以存储在许多不同的文件和压缩格式中,在它们之间进行转换可能非常麻烦。在.NET应用程序中尤其困难,因为框架类库几乎不支持各种Windows API进行音频压缩和解压缩。在本文中,我将解释不同类型的音频文件格式,以及您需要经历哪些步骤才能在格式之间进行转换。然后,我将解释Windows提供的主要与音频编解码器相关的API。最后,我将展示一些在.NET中转换各种格式文件的实际示例,并利用我的开源NAudio库。

理解音频格式

在开始尝试在格式之间转换音频之前,您需要了解音频存储的一些基本知识。如果您已经知道了,请随意跳过本节,但如果您想避免在尝试进行的转换不允许时感到沮丧,那么对一些关键概念有一个基本的掌握是很重要的。首先要理解的是压缩和未压缩音频格式之间的区别。所有音频格式都属于这两个大类别之一。     

未压缩音频 (PCM)

未压缩音频,或线性PCM,是您的声卡希望使用的格式。它由一系列“样本”组成。每个样本都是一个数字,表示在某个时间点的音频响度。最常见的采样率之一是44.1kHz,这意味着我们每秒记录信号的电平44100次。这通常存储为16位整数,所以每秒您将存储88200字节。如果您的信号是立体声,则存储一个左样本后跟一个右样本,那么每秒将需要176400字节。这就是音频CD使用的格式。    

PCM音频有三种主要变体。首先,有多种不同的采样率。44.1kHz用于音频CD,而DVD通常使用48kHz。较低的采样率有时用于语音通信(例如电话和无线电),如16kHz甚至8kHz。质量会降低,但对于语音来说通常足够了(音乐听起来不会那么好)。有时在专业的录音棚里,会使用更高的采样率,如96kHz,尽管这有什么好处尚有争议,因为44.1kHz足以记录人类耳朵能听到的最高频率的声音。值得注意的是,您不能随意选择采样率。大多数声卡只支持有限的采样率子集。最常用的支持值是8kHz、16kHz、22.05kHz、16kHz、32kHz、44.1kHz和48kHz。 

其次,PCM可以以不同的位深度记录。16位是迄今为止最常见的,也是您应该默认使用的。它存储为有符号值(-32768至+32767),一个静音文件将包含全零。我强烈建议不要使用8位PCM。听起来很糟糕。除非您想创建特殊的复古音效,否则不应使用它。如果您想节省空间,有更好的方法可以减小音频文件的大小。24位常用于录音棚,因为它即使在较低的录音水平下也能提供充足的分辨率,这对于降低“削波”的可能性是有益的。24位可能很难处理,因为您需要弄清楚样本是连续存储的,还是在它们之间插入了一个额外的字节以将其对齐到四字节。

您需要了解的最后一个位深度是32位IEEE浮点(在.NET世界中,这称为“float”或“Single”)。虽然32位的分辨率对于单个音频文件来说绰绰有余,但在混合文件时却极其有用。如果您要混合两个16位文件,很容易产生溢出,因此通常您会将其转换为32位浮点(其中-1和1代表16位文件的最小和最大值),然后将它们混合在一起。现在范围可能在-2到+2之间,所以您可能需要降低混合文件的总体音量以避免在转换回16位时产生削波。虽然32位浮点音频是一种PCM,但通常不称其为PCM,以免与表示为32位整数的PCM(虽然罕见但确实存在)混淆。它通常被称为“浮点”音频。

注意:还有其他位深度 - 某些系统使用20位或32位整数。某些混音程序使用64位双精度浮点数而不是32位,尽管以如此高的位深度将音频文件写入磁盘会非常罕见。另一个复杂之处在于,您有时需要知道样本是以“大端”还是“小端”格式存储的。但您应该遇到的最常见的两个位深度是16位PCM和32位浮点。   

PCM的第三个主要变体是声道数。这通常是1(单声道)或2(立体声),但您当然可以有更多(例如5.1,这对于电影音轨来说很常见)。每个声道的样本是交错存储的,一对或一组样本有时被称为“帧”。

未压缩音频容器

您不能直接将PCM样本写入磁盘,然后指望媒体播放器知道如何播放它。它将无法知道您使用的是什么采样率、位深度和声道数。因此,PCM样本被放入一个容器中。在Windows中,PCM文件的通用容器格式是WAV文件。

WAV文件包含多个“块”。其中两个最重要的块是格式块数据块。格式块包含一个WAVEFORMAT结构(可能还有一些额外的字节),该结构指示数据块中音频的格式。这包括它是PCM还是IEEE浮点,并指示采样率、位深度和声道数。为了方便起见,它还包含其他有用信息,例如每秒的平均字节数(尽管对于PCM,您可以轻松地自己计算)。 

WAV不是存储PCM的唯一容器格式。如果您处理的文件来自Mac OS,它们可能是AIFF文件。需要注意的一个主要区别是,AIFF文件通常使用大端字节序来存储样本,而WAV文件使用小端字节序。 

压缩音频格式 

有无数的音频压缩格式(也称为“编解码器”)。它们共同的目标是减少音频所需的存储空间,因为PCM占用大量磁盘空间。为了实现这一点,声音质量经常会做出各种妥协,尽管有一些“无损”音频格式,如“FLAC”或Apple Lossless(ALAC),它们在概念上类似于压缩WAV文件。它们解压缩到您压缩的完全相同的PCM。 

压缩音频格式分为两大类。一类旨在在尽可能保留音频保真度的同时减小文件大小。这包括MP3、WMA、VorbisAAC等格式。它们最常用于音乐,通常可以在不显着降低音质的情况下实现约10倍的尺寸减小。此外,还有如Dolby Digital这样的格式,它们考虑了电影中环绕声的需求。 

另一类是专门为语音通信设计的编解码器。这些通常要激进得多,因为它们可能需要实时传输。质量大大降低,但它允许快速传输。一些语音编解码器考虑的另一个因素是编码和解码所需的处理器工作。如今的移动处理器已经足够强大,因此这不再是一个主要考虑因素,但这可以解释为什么某些电话编解码器如此简陋。其中一个例子是G.711,或mu和a-law,它只是将每个16位样本转换为8位样本,所以在某种意义上它仍然是一种PCM(尽管不是线性的)。其他常见的电话或无线电编解码器包括ADPCMGSM 610G.722G.723.1G.729aIMBE/AMBEACELP。还有许多更针对互联网电话场景的编解码器,如Speex、Windows Media Voice和Skype的编解码器SILK。  

正如您所见,音频编解码器种类繁多,而且还在不断创建(opus是一个特别有趣的新编解码器)。您将无法编写支持所有这些的程序,但可以覆盖其中的大部分。 

压缩音频容器   

压缩音频的容器开始变得非常棘手。WAV文件格式实际上可以包含我之前提到的大部分编解码器。WAV文件的格式块足够灵活(尤其是在引入WAVEFORMATEXTENSIBLE后),可以定义几乎任何内容。但WAV文件格式本身也有局限性(例如,需要在头中报告文件长度,不支持非常大的文件,对添加元数据(如专辑封面)支持不佳)。因此,许多压缩音频类型都有自己的容器格式。例如,MP3文件仅由一系列压缩的MP3数据块组成,并在开头或结尾添加了可选的元数据部分。WMA文件使用Microsoft的ASF格式。AAC文件可以使用MP4容器格式。这意味着,与WAV一样,音频文件通常包含的不仅仅是编码的音频数据。您或您使用的解码器都需要了解如何从存储的容器中提取压缩音频。 

比特率和块对齐 

大多数编解码器提供各种比特率。比特率是存储一秒钟音频所需的平均比特数。您在初始化编码器时选择所需的比特率。编解码器可以是恒定比特率(CBR)或可变比特率(VBR)。在恒定比特率编解码器中,相同数量的输入字节总是会变成相同数量的输出字节。这使得导航编码文件变得容易,因为每个压缩块的字节数都相同。这个块的大小有时被称为“块对齐”值。如果您正在解码CBR文件,每次应尝试只给解码器提供“块对齐”的整数倍以进行解码。对于VBR文件,编码器可以根据需要更改比特率,从而实现更高的压缩率。缺点是难以在文件中定位,因为文件的一半可能并不意味着音频的一半。此外,解码器可能需要能够处理被提供不完整块以进行解码的情况。 

转换管道 

现在我们已经了解了压缩和未压缩音频格式的基础知识,我们需要考虑我们正在进行的转换。您通常会做三件事之一。第一是解码,您将一种压缩音频类型转换为PCM。第二是编码,您将PCM转换为一种压缩格式。但是,您不能直接从一种压缩格式转换为另一种。这被称为转码,它涉及首先解码为PCM,然后编码为另一种格式。中间可能还有一个额外的步骤,因为有时您需要将一种PCM格式转码为另一种。

解码 

对于给定的输入类型,每个解码器都有一个首选的PCM输出格式。例如,您的MP3文件可能原生解码为44.1kHz立体声16位,而G.711文件将解码为8kHz单声道16位。如果您想要浮点输出,或32kHz,您的解码器*可能*愿意满足,但通常您必须自己完成另一个阶段。  

编码 

同样,您的编码器不太可能接受任何类型的PCM作为输入。它将有特定的约束。通常支持单声道和立体声,并且大多数编解码器在采样率方面很灵活。但位深度几乎总是需要是16位。 您还应该永远不要尝试在编码文件的中间更改编码器的输入格式。虽然某些文件格式(例如MP3)在技术上允许在文件中间更改采样率和声道数,但这使得任何试图播放该文件的人都非常困难。

转码PCM 

您现在应该意识到,某些转换无法一步完成。从压缩转换为PCM后,您可能需要更改为另一种PCM变体。或者您可能已经拥有PCM,但它不是您编码器所需的格式。PCM可以通过三种方式进行更改,这些通常作为三个单独的阶段完成,尽管您可以创建一个结合了它们的转码器。这些是更改采样率(称为重采样)、更改位深度和更改声道数。 

更改PCM声道数

修改PCM声道数可能是最简单的更改。要从单声道转换为立体声,只需重复每个样本。因此,例如,如果我们有一个名为input的字节数组,其中包含16位单声道样本,并且我们想将其转换为立体声,我们所要做的就是

private byte[] MonoToStereo(byte[] input)
{
    byte[] output = new byte[input.Length * 2];
    int outputIndex = 0;
    for (int n = 0; n < input.Length; n+=2)
    {
        // copy in the first 16 bit sample
        output[outputIndex++] = input[n];
        output[outputIndex++] = input[n+1];
        // now copy it in again
        output[outputIndex++] = input[n];
        output[outputIndex++] = input[n+1];        
    }
    return output;
}

立体声转单声道怎么样?在这里我们有一个选择。最简单的方法是丢弃一个声道。在此示例中,我们保留左声道并丢弃右声道

private byte[] StereoToMono(byte[] input)
{
    byte[] output = new byte[input.Length / 2];
    int outputIndex = 0;
    for (int n = 0; n < input.Length; n+=4)
    {
        // copy in the first 16 bit sample
        output[outputIndex++] = input[n];
        output[outputIndex++] = input[n+1];
    }
    return output;
}

或者,我们可能想混合左右声道。这意味着我们实际上需要访问样本值。如果是16位,这意味着每两个字节都必须转换为Int16。您可以使用位运算来实现这一点,但在这里我将展示BitConverter辅助类的用法。我通过将样本相加然后除以二来混合样本。请注意,我使用了32位整数来执行此操作,以防止溢出问题。但当我准备写出样本时,我将其转换回16位数字,并使用BitConverter将其转换为字节。

private byte[] MixStereoToMono(byte[] input)
{
    byte[] output = new byte[input.Length / 2];
    int outputIndex = 0;
    for (int n = 0; n < input.Length; n+=4)
    {
        int leftChannel = BitConverter.ToInt16(input,n);
        int rightChannel = BitConverter.ToInt16(input,n+2);
        int mixed = (leftChannel + rightChannel) / 2;
        byte[] outSample = BitConverter.GetBytes((short)mixed);
        
        // copy in the first 16 bit sample
        output[outputIndex++] = outSample[0];
        output[outputIndex++] = outSample[1];
    }
    return output;
}

当然,还有其他策略可以用来改变声道数,但这些是最常见的。

更改PCM位深度

更改PCM位深度也相对简单,尽管处理24位可能很棘手。 让我们从一个更常见的转换开始,从16位转换为32位浮点。我将再次想象我们有一个字节数组中的16位PCM,但这次我们将它作为float数组返回,这样更容易进行分析或DSP。当然,如果您愿意,可以使用BitConverter将位放回字节数组中。

public float[] Convert16BitToFloat(byte[] input)
{
    int inputSamples = input.Length / 2; // 16 bit input, so 2 bytes per sample
    float[] output = new float[inputSamples];
    int outputIndex = 0;
    for(int n = 0; n < inputSamples; n++)
    {
        short sample = BitConverter.ToInt16(input,n*2);
        output[outputIndex++] = sample / 32768f;
    }
    return output;
}

我为什么要除以32768,而Int16.MaxValue是32767?答案是Int16.MinValue是-32768,所以我知道我的音频完全在±1.0范围内。如果它超出了±1.0,一些音频程序会将其解释为削波,这可能看起来很奇怪,如果您知道您没有以任何方式放大音频。说实话,这并不重要,只要您在返回16位时小心不要产生削波,我们稍后会回到这一点。 

24位音频怎么样?这取决于音频的布局方式。在此示例中,我们将假设它紧密排列在一起。为了利用BitConverter,我们将每3个字节复制到一个4字节的临时缓冲区中,然后转换为int。然后,我们将除以最大的24位值,以再次进入±1.0范围。请注意,使用BitConverter不是最快的方法。我通常会创建一个带有BitConverter的实现作为参考,然后用它来检查我的位操作代码。 

public float[] Convert24BitToFloat(byte[] input)
{
    int inputSamples = input.Length / 3; // 24 bit input
    float[] output = new float[inputSamples];
    int outputIndex = 0;
    var temp = new byte[4];
    for(int n = 0; n < inputSamples; n++)
    {
        // copy 3 bytes in
        Array.Copy(input,n*3,temp,0,3);
        int sample = BitConverter.ToInt32(temp,0);
        output[outputIndex++] = sample / 16777216f;
    }
    return output;
}

反过来怎么样,比如从浮点数回到16位?这很容易,但在此时我们需要决定如何处理“削波”的样本。您可以简单地抛出异常,但更常见的是使用“硬限制”,即超出范围的任何样本都将被设置为其最大值。这是一个代码示例,显示我们读取一些浮点样本,调整它们的音量,然后进行削波,再将16位样本写入Int16数组。

for (int sample = 0; sample < sourceSamples; sample++)
{
    // adjust volume
    float sample32 = sourceBuffer[sample] * volume;
    // clip
    if (sample32 > 1.0f)
        sample32 = 1.0f;
    if (sample32 < -1.0f)
        sample32 = -1.0f;
    destBuffer[destOffset++] = (short)(sample32 * 32767);
}

重采样

重采样是PCM最难正确执行的转换。第一个问题是给定输入样本数,输出样本数不一定是整数。第二个问题是重采样可能会引入不需要的伪影,如“混叠”。这意味着理想情况下您希望使用由知道自己在做什么的人编写的算法。 

您可能会认为对于某些采样率转换来说,这是一个简单的任务。例如,如果您有16kHz音频并想要8kHz音频,您可以丢弃每隔一个样本。要从8kHz到16kHz,您可以在每个原始样本之间插入一个额外的样本。但这个额外样本的值应该是多少?应该是0吗?还是我们应该重复每个样本?或者我们也许可以计算一个中间值——前一个和后一个样本的平均值。这被称为“线性插值”,如果您有兴趣了解更多关于插值策略的信息,可以从这里开始查看这里

处理混叠问题的一种方法是将音频通过低通滤波器(LPF)。如果音频文件以48kHz采样,它可以包含的最高频率是该值的一半(如果您想了解原因,请阅读奈奎斯特定理)。因此,如果您重采样到16kHz,原始文件中的任何高于8kHz的频率都可能“混叠”为重采样文件中的低频噪声。因此,最好在降采样之前过滤掉任何高于8kHz的声音。如果您反过来,例如将16kHz文件重采样到44.1kHz,那么您希望生成的文件不包含高于8kHz的任何信息,因为原始文件没有。但您可以在转换后运行低通滤波器,以消除重采样产生的任何伪影。  

稍后我们将讨论如何使用他人的重采样算法,但目前我们假设我们冒险(并违背常识),并想实现自己的“朴素”重采样算法。我们可以这样做: 

// Just about worst resampling algorithm possible:
private float[] ResampleNaive(float[] inBuffer, int inputSampleRate, int outputSampleRate)
{
    var outBuffer = new List<float>();
    double ratio = (double) inputSampleRate / outputSampleRate;
    int outSample = 0;
    while (true)
    {
        int inBufferIndex = (int)(outSample++ * ratio);
        if (inBufferIndex < read)
            writer.WriteSample(inBuffer[inBufferIndex]);
        else
            break;    
    } 
    return outBuffer.ToArray();    
}

测试您的重采样器 

现在,如果您在语音录音上尝试此算法,您会惊喜地发现它听起来相当不错。也许重采样并不复杂。而且对于语音来说,这种朴素的方法可能勉强可行。毕竟,音频最重要的测试是“用耳朵听”测试,如果您喜欢听到的声音,那又何妨您的算法不是最优的呢? 

然而,当我们输入不同类型的文件时,这种重采样方法的局限性就变得非常明显了。检查重采样器质量的最佳测试信号之一是正弦波扫描。这是一种正弦波信号,从低频开始,然后随时间逐渐增加频率。开源的Audacity音频编辑器可以生成这些(选择“Generate | Chirp ...”)。您应该从低频(例如20Hz,人耳能听到的最低频率)开始,一直到输入文件的采样率的一半。一个警告——播放这些文件时要非常小心,尤其是在使用耳机时。您可能会发现这是一次非常不愉快和痛苦的经历。先将音量调低。

我们可以使用任何绘制频谱图的音频程序来获取此文件的可视化表示。我最喜欢的是Schwa开发的VST插件Spectro,但也有很多程序可以从WAV文件中绘制这些图。基本上,X轴代表时间,Y轴代表频率,所以我们的扫描看起来是这样的

如果我们将此文件降采样到16kHz,生成的文件将无法包含任何高于8kHz的频率,但我们应该期望图的第一部分保持不变。让我们看看Media Foundation Resampler在其最高质量设置下表现如何。 

不算太差。您可以看到在截止频率之前有非常轻微的混叠。这是因为Media Foundation使用低通滤波器来尝试消除所有高于8kHz的频率,但真实世界的滤波器并不完美,所以必须做出一些妥协。

现在让我们看看我们的朴素算法表现如何。请记住,我们根本没有进行任何过滤。 

哇!正如您所看到的,各种额外的噪音都进入了我们的文件。如果您听一下,您仍然会听到主要的扫描作为原始声音,但它会继续超过它应该截止的点,最终我们得到了一些听起来像奇怪的迷幻科幻配乐的东西。重要的是,如果您需要重采样,您最好不要尝试编写自己的算法。 

我可以使用什么编解码器?  

对于.NET开发者来说,当考虑要使用哪些音频编解码器时,您有两个选择。第一个是利用各种Windows API,让您可以访问PC上已安装的编码器和解码器。第二个是自己编写编解码器,或者更可能的是,为第三方DLL编写P/Invoke包装器。我们将主要关注第一种方法,并在后面简要介绍第二种方法的示例。

Windows中有三个主要的API用于编码和解码音频。它们是ACM、DMO和MFT。

音频压缩管理器(ACM)

音频压缩管理器是服务时间最长、支持最广泛的Windows音频编解码器API。ACM编解码器是扩展名为.acm的DLL,并作为“驱动程序”安装在您的系统上。Windows XP及更高版本带有一小部分这些驱动程序,涵盖了G.711、GSM、ADPCM等几种电话编解码器,还带有一个MP3解码器。奇怪的是,Microsoft没有选择将Windows Media Format解码器作为ACM。关于ACM需要注意的一点是,大多数都是32位。因此,如果您在64位进程中运行,通常只能访问Windows自带的那些,因为它们是x64兼容的。

枚举ACM编解码器

要查找您系统中已安装的ACM编解码器,您需要调用acmDriverEnum函数,并传递一个回调函数。然后,每次调用回调函数时,您将使用传递给您的驱动程序句柄调用acmDriverDetails,它将填充ACMDRIVERDETAILS结构的一个实例。仅此而已,这并不能为您提供太多有用的信息,除了编解码器名称,然后您可以要求驱动程序提供它支持的“格式标签”列表。您可以通过调用acmFormatTagEnum来做到这一点,它需要一个回调函数,该函数将为每个格式标签返回一个填充的ACMFORMATTAGDETAILS结构。

每个ACM编解码器通常有两个格式标签。一个是它解码或编码到的格式,另一个是PCM。但是,这只是格式的高级描述。要获取可能的输入和输出格式的实际详细信息,我们必须进行另一个枚举,即调用acmFormatEnum,并传递格式标签。同样,这需要使用一个回调函数,该函数将在每个可用于该编解码器输入或输出的有效格式时被调用。每个回调都提供一个 ACMFORMATDETAILS结构,其中包含格式的详细信息。最重要的是,它包含一个指向WAVEFORMATEX结构的指针。这非常重要,因为通常是WAVEFORMATEX结构用于获取正确的编解码器,并告诉它您想从什么转换到什么。

不幸的是,WAVEFORMATEX在.NET环境中进行封送处理非常棘手,因为它是一个可变长度结构,末尾带有任意数量的“额外字节”。我的方法是使用一个特殊的结构版本进行封送处理,该版本在末尾有足够的备用字节。我还使用自定义封送处理程序来更轻松地处理已知的Wave格式。

当您处理第三方ACM编解码器时,您通常需要使用十六进制编辑器检查这些WAVEFORMATEX结构,以确保您可以传递一个完全正确的结构。如果您想创建一个Windows Media Player可以播放的WAV文件,您也需要它,因为它将使用此WAVEFORMATEX结构来搜索ACM解码器。   

如果这一切听起来像为了找出您可以使用哪些编解码器而要做大量工作,那么好消息是,我已经编写了所有这些互操作代码。这是用于枚举ACM编解码器并打印出它支持的所有格式标签和格式的详细信息的代码。

foreach (var driver in AcmDriver.EnumerateAcmDrivers())
{
    StringBuilder builder = new StringBuilder();
    builder.AppendFormat("Long Name: {0}\r\n", driver.LongName);
    builder.AppendFormat("Short Name: {0}\r\n", driver.ShortName);
    builder.AppendFormat("Driver ID: {0}\r\n", driver.DriverId);
    driver.Open();
    builder.AppendFormat("FormatTags:\r\n");
    foreach (AcmFormatTag formatTag in driver.FormatTags)
    {
        builder.AppendFormat("===========================================\r\n");
        builder.AppendFormat("Format Tag {0}: {1}\r\n", formatTag.FormatTagIndex, formatTag.FormatDescription);
        builder.AppendFormat("   Standard Format Count: {0}\r\n", formatTag.StandardFormatsCount);
        builder.AppendFormat("   Support Flags: {0}\r\n", formatTag.SupportFlags);
        builder.AppendFormat("   Format Tag: {0}, Format Size: {1}\r\n", formatTag.FormatTag, formatTag.FormatSize);
        builder.AppendFormat("   Formats:\r\n");
        foreach (AcmFormat format in driver.GetFormats(formatTag))
        {
            builder.AppendFormat("   ===========================================\r\n");
            builder.AppendFormat("   Format {0}: {1}\r\n", format.FormatIndex, format.FormatDescription);
            builder.AppendFormat("      FormatTag: {0}, Support Flags: {1}\r\n", format.FormatTag, format.SupportFlags);
            builder.AppendFormat("      WaveFormat: {0} {1}Hz Channels: {2} Bits: {3} Block Align: {4}, 
                AverageBytesPerSecond: {5} ({6:0.0} kbps), Extra Size: {7}\r\n",
                format.WaveFormat.Encoding, format.WaveFormat.SampleRate, format.WaveFormat.Channels,
                format.WaveFormat.BitsPerSample, format.WaveFormat.BlockAlign, 
                 format.WaveFormat.AverageBytesPerSecond,
                (format.WaveFormat.AverageBytesPerSecond * 8) / 1000.0,
                format.WaveFormat.ExtraSize);
            if (format.WaveFormat is WaveFormatExtraData && format.WaveFormat.ExtraSize > 0)
            {
                WaveFormatExtraData wfed = (WaveFormatExtraData)format.WaveFormat;
                builder.Append("      Extra Bytes:\r\n      ");
                for (int n = 0; n < format.WaveFormat.ExtraSize; n++)
                {
                    builder.AppendFormat("{0:X2} ", wfed.ExtraData[n]);
                }
                builder.Append("\r\n");
            }
        }
    }
    driver.Close();
    Console.WriteLine(builder.ToString());
} 

ACM还有一个不错的技巧。它可以根据压缩格式为您建议一个PCM格式。这意味着您无需费力地弄清楚需要将输出格式提供给解码器。该函数称为acmFormatSuggest,我们将在后面的示例中看到它的作用。

重要的是要注意,虽然ACM严重依赖WAVEFORMATEX结构,但这并不意味着它只能用于WAV文件。例如,MP3文件可以通过ACM编解码器进行编码和解码。您只需要弄清楚MP3解码器期望的相应WAVEFORMATEX结构,构建一个并传递它。 

DirectX Media Objects (DMO)  

DirectX Media Objects是DirectX API集合的一部分,我认为它是ACM的预期后继者,但最终被MFT(见下一节)取代。它有一个庞大而蔓延的COM基础API,在.NET应用程序中很难使用。许多DMO实际上也实现了MFT接口,因此可以从任一API使用。可能没有多大意义去麻烦这个API,因为我认为它在Windows应用商店应用中不支持。我的建议是,对于旧版OS支持(Windows XP),使用ACM,对于任何更新的系统,使用MFT。 

Media Foundation Transforms (MFT)

Microsoft Media Foundation是一个新API,随Windows Vista推出,实际上使ACM过时。它是一个更强大的API,但对于.NET开发者来说也更令人生畏,因为它基于COM,并且需要编写大量的互操作代码才能完成任何工作。它还大量使用GUID - 每个媒体类型一个GUID,以及每个媒体类型的每个属性(“属性”)一个GUID。 虽然ACM在Windows 8中仍然可用,但值得关注Media Foundation的一个重要原因是它是Windows应用商店应用中唯一可用的编码和解码API。Windows Phone 8还支持此API的有限子集,这意味着Media Foundation绝对是Windows平台上音频编解码器的未来。 

关于Media Foundation需要注意的一个重要事项是,与ACM不同,它不是一个仅限音频的API。它还涵盖视频解码器、编码器和过滤器。这也是该API在处理音频时看起来相当复杂的原因之一,因为它试图成为任何类型媒体的通用目的。然而,这种方法的一个非常好的好处是,您可以播放或提取视频文件的音频。您甚至可以使用它来为视频添加自己的配乐。 

Windows提供了相当不错的Media Foundation音频解码器,包括播放MP3和WMA文件的能力。Windows 7包含一个AAC解码器和编码器,让您可以处理Apple偏好的格式。至于编码器,选择稍微有限一些。从Windows Vista开始,应该有一个Windows Media Encoder,Windows 8带有一个MP3编码器。这篇文章是了解不同Windows版本上期望的程序的有用指南。

除了编码和解码音频之外,Media Foundation转换还可以用于音频效果。Media Foundation中值得注意的主要音频效果是Resampler MFT。我的Windows 8 PC还有一个AEC(声学回声消除)效果,如果您正在实现类似Skype的程序,这将非常有用,尽管AEC效果可能很难正确配置(因为您需要提供两个输入流)。

枚举Media Foundation编解码器

我们已经了解了如何以编程方式查找您已安装的ACM编解码器。如何在Media Foundation中做同样的事情?首先,我们可以使用MFTEnumEx函数(或Windows Vista的MFTEnum)来查找已安装的音频编码器、解码器和效果。它返回指向IMFActivate COM接口数组的指针,这对.NET互操作来说不是最友好的格式。您可以使用一些指针算术和Marshal.GetObjectForIUnknown为每个IMFActivate来遍历数组。完成后,您需要记住在数组指针上调用Marshal.FreeCoTaskMem

以下是一个示例代码,演示如何枚举转换。类别GUID是MFT_CATEGORY列表中的一个项目,允许您仅查找音频解码器、编码器或效果。

public static IEnumerable<IMFActivate> EnumerateTransforms(Guid category)
{
    IntPtr interfacesPointer;
    IMFActivate[] interfaces;
    int interfaceCount;
    MediaFoundationInterop.MFTEnumEx(category, _MFT_ENUM_FLAG.MFT_ENUM_FLAG_ALL,
        null, null, out interfacesPointer, out interfaceCount);
    interfaces = new IMFActivate[interfaceCount];
    for (int n = 0; n < interfaceCount; n++)
    {
        var ptr =
            Marshal.ReadIntPtr(new IntPtr(interfacesPointer.ToInt64() + n*Marshal.SizeOf(interfacesPointer)));
        interfaces[n] = (IMFActivate) Marshal.GetObjectForIUnknown(ptr);
    }

    foreach (var i in interfaces)
    {
        yield return i;
    }
    Marshal.FreeCoTaskMem(interfacesPointer);
} 

IMFActivate接口实际上允许您创建IMFTransform对象的实例,但我们目前不需要这样做。相反,我们利用了IMFActivate派生自IMFAttributes(Media Foundation中的一个通用基类)的事实,它充当属性存储,包含有关编解码器的有用信息。每个属性都由一个Guid索引,因此如果您知道要查找的内容,则可以使用已知的Guid请求属性。或者,您可以枚举每个属性,看看每个转换对其自身说了什么。 

要找出有多少属性,请调用IMFAttributes.GetCount,然后您可以反复调用IMFAttributes.GetItemByIndex,索引递增。这将把属性读入一个PROPVARIANT结构,由于它包含一个大的C++联合,因此很难编写互操作代码,但基本上可以使用使用StructLayout(LayoutKind.Explicit)声明的结构在C#中进行复制。幸运的是,在PROPVARIANT中可以包含的众多类型中,Media Foundation只使用有限的选择(UINT32、UINT64、double、GUID、宽字符字符串、字节数组或IUnknown指针)。如果它是字节数组(在Media Foundation中是VT_VECTOR | VT_UI1,而不是VT_BLOB),您可能需要调用PropVariantClear来释放内存。

完成此操作后,您将得到一个Guid列表,以及值(其中一些也将是Guid),这些值可能对您没有意义(除非您具有记忆Guid的非凡能力)。反向查找Guid到其含义是一个乏味的过程,但一个对我非常有用的很酷的网站是UUID查找网站。或者,您可以搜寻Windows SDK头文件,查找Guid、接口定义和错误代码。以下是我在LINQPad中的一段代码,可以帮助我快速搜索Media Foundation头文件。

Directory.EnumerateFiles(@"C:\Program Files (x86)\Microsoft SDKs\Windows\v7.1A\Include\","mf*.h")
    .SelectMany(f => File.ReadAllLines(f).Select((l,n) => new { File=f, Line=l, LineNumber=n+1  }))    
    .Where(l => l.Line.Contains("MF_E_ATTRIBUTENOTFOUND")) 

以下是在这些激活对象上找到的一些关键属性:

  • MFT_FRIENDLY_NAME_Attribute(字符串)友好名称
  • MF_TRANSFORM_CATEGORY_Attribute(Guid)告诉您这是音频编码器、解码器还是效果。它是MFT_CATEGORY列表中的一个值。   
  • MFT_TRANSFORM_CLSID_Attribute(Guid)此转换的类标识符。如果您想直接创建它的实例,而不是通过IMFActivate实例创建,则很有用。 
  • MFT_INPUT_TYPES_Attributes(字节数组)这是一个指向MFT_REGISTER_TYPE_INFO结构实例的指针数组,这也是一种不友好的向.NET呈现数据的方式。我创建了一个帮助函数(如下所示),它允许我使用Marshal.PtrToStructure将此字节数组中的指针封送到结构实例。对于音频解码器,此列表很重要,因为它表明它可以解压缩哪些类型的音频。
  • MFT_OUTPUT_TYPES_Attributes(字节数组)这类似于输入类型,但对于编码器更有用。对于解码器,它通常是PCM。一些编码器支持多种输出类型。例如,Windows Media Audio编解码器有几种不同的变体,包括常规WMA、WMA Professional和WMA Lossless。

这是我用于访问PROPVARIANT字节数组中存储的结构指针数组的帮助函数:

public T[] GetBlobAsArrayOf<T>()
{
    var blobByteLength = blobVal.Length;
    var singleInstance = (T) Activator.CreateInstance(typeof (T));
    var structSize = Marshal.SizeOf(singleInstance);
    if (blobByteLength%structSize != 0)
    {
        throw new InvalidDataException(String.Format("Blob size {0} not a multiple of struct size {1}", blobByteLength, structSize));
    }
    var items = blobByteLength/structSize;
    var array = new T[items];
    for (int n = 0; n < items; n++)
    {
        array[n] = (T) Activator.CreateInstance(typeof (T));
        Marshal.PtrToStructure(new IntPtr((long) blobVal.Data + n*structSize), array[n]);
    }
    return array;
}

一旦您费心解码了这个MFT_REGISTER_INFO,您又会遇到几个不友好的Guid——一个“主类型”和一个“子类型”。主类型是此列表中的Guid,很可能是MFMediaType_Audio。如果主类型是音频,那么子类型将是众多可能的音频子类型之一。 此列表包含最常见的子类型,但任何人都可以创建自己的具有自己Guid的音频子类型。 您可能会发现最有用的子类型是:

  • MFAudioFormat_PCM 用于所有以整数格式存储的未压缩PCM
  • MFAudioFormat_Float 用于32位浮点IEEE音频
  • MFAudioFormat_MP3 MP3
  • MFAudioFormat_WMAudioV8 (奇怪的是,这也包括Windows Media Audio 9。MFAudioFormat_WMAudioV9用于指Windows Media Audio Professional)
  • MFAudioFormat_AAC AAC

我将所有这些Guid转换为有意义字符串的方法之一是使用我的定义文件中的属性。例如: 

[FieldDescription("Windows Media Audio")]
public static readonly Guid MFAudioFormat_WMAudioV8 = new Guid("00000161-0000-0010-8000-00aa00389b71");
[FieldDescription("Windows Media Audio Professional")]
public static readonly Guid MFAudioFormat_WMAudioV9 = new Guid("00000162-0000-0010-8000-00aa00389b71");

这种方法允许我使用反射来查找匹配的Guid并提取描述或属性名称(如果可用)。

当我们实际使用编解码器时,我们需要的不仅仅是主类型和子类型。与ACM中使用的WAVEFORMAT结构一样,编解码器需要有关采样率、位深度、声道数等信息。编码器通常还提供“比特率”选择。比特率越高,音频质量越好,文件越大。您需要在文件大小或音频质量之间进行权衡。 

为了存储有关音频格式的这些额外信息,Media Foundation使用了“媒体类型”的概念,由IMFMediaType接口表示。与IMFActivate接口一样,它继承自IMFAttributes,其键值存储包含音频格式的信息。值得庆幸的是,大多数重要参数都是整数。以下是您将在音频媒体类型上找到的一些关键属性:

  • MF_MT_MAJOR_TYPE -(Guid)主媒体类型(音频)
  • MF_MT_SUBTYPE -(Guid)来自上面列表的音频子类型
  • MF_MT_AUDIO_SAMPLES_PER_SECOND -(UINT)采样率(例如44100)
  • MF_MT_AUDIO_NUM_CHANNELS -(UINT)声道数,通常为1(单声道)或2(立体声)
  • MF_MT_AUDIO_BITS_PER_SAMPLE -(UINT)每个样本的位数,对于PCM最相关,但有时压缩格式支持更高分辨率的音频(例如,WMA Professional具有24位音频模式)
  • MF_MT_AUDIO_AVG_BYTES_PER_SECOND(UINT)每秒平均字节数。乘以8得到比特率。(还有一个MF_MT_AVG_BITRATE,但它并非总是存在)

您可以使用MFCreateMediaType函数创建一个新的媒体类型对象,然后向其中添加属性。您还可以通过其他方式获取它们,例如调用MFTranscodeGetAudioOutputAvailableTypes,我稍后将演示如何使用Media Foundation进行编码。

使用NAudio编码和解码的示例

如果您已经完成了,恭喜您。我们现在准备展示C#中解码和编码文件的实际示例。虽然将这些示例的完整源代码放入本文中会很好,但它也会使文章非常长,因为某些API所需的互操作包装器类非常冗长。相反,我将演示使用我自己的.NET音频库NAudio,这是我希望别人为我写的库。

我已经为NAudio工作了10年,它仍然没有做到我想要的一切。我也得到了许多人的帮助,包括CodeProject上的一些作者,感谢所有分享知识的人——关于如何使用Windows音频API的信息并不总是够多。还有其他.NET音频库,它们将包装相同的Windows API,因此您很可能在这些库中通过类似的代码实现相同的结果。NAudio是开源的,因此您可以借用和改编其代码供您自己使用。 

对于每一类编码或解码,我将解释NAudio在后台为您所做的,并展示实现转换的最简单代码。   

使用ACM将压缩音频转换为PCM WAV

虽然选择MP3作为我们的第一个示例很诱人,但从一种恒定比特率并且存储在WAV文件中的格式开始会更简单。让我们假设我们有一个GSM编码的WAV文件,并想将其转换为PCM WAV文件。 

NAudio提供了两个用于读取和写入WAV文件的帮助类——WaveFileReaderWaveFileWriter类。WaveFileReader能够将WAV格式块读取到WaveFormat对象中(通过WaveFormat属性访问),而Read方法仅从WAV文件的数据块中读取音频数据。 

using(var reader = new WaveFileReader("gsm.wav"))
using(var converter = WaveFormatConversionStream.CreatePcmStream(reader)) {
    WaveFileWriter.CreateWaveFile("pcm.wav", converter);
} 

相当简单,事实上,这就是在NAudio中将几乎任何包含压缩音频的WAV文件转换为PCM所需要的所有内容。但它是如何工作的呢?

首先要注意的是,我们调用了WaveFormatConversionStream.CreatePcmStream。它的作用是调用acmFormatSuggest。我们预先填充了传递进去的WaveFormat,指定编码为PCM,采样率和声道数与压缩文件相同,位深度为16。因为这是任何给定压缩格式最有可能解码到的格式。但acmFormatSuggest允许编解码器告诉我们它会将给定压缩格式解码成的确切PCM格式。

public static WaveFormat SuggestPcmFormat(WaveFormat compressedFormat)
{
    WaveFormat suggestedFormat = new WaveFormat(compressedFormat.SampleRate, 16, compressedFormat.Channels);
    MmException.Try(AcmInterop.acmFormatSuggest(IntPtr.Zero, compressedFormat, 
      suggestedFormat, Marshal.SizeOf(suggestedFormat), AcmFormatSuggestFlags.FormatTag), 
      "acmFormatSuggest");
    return suggestedFormat;
}

在确定了所需的输出格式后,现在我们创建WaveFormatConversionStream的一个新实例,这是NAudio封装ACM转换流的方式。WaveFormatConversionStream的构造函数调用acmStreamOpen,传递所需的输入和输出格式。Windows将依次查询每个ACM编解码器,询问它是否可以执行所需的转换。如果没有编解码器能够执行,您将收到一个ACMERR_NOTPOSSIBLE错误。例如,假设我们更改示例,指定我们希望将GSM编码的WAV文件转换为44.1kHz立体声24位。 

var desiredOutputFormat = new WaveFormat(44100,24,2);
using(var reader = new WaveFileReader("gsm.wav"))
using(var converter = new WaveFormatConversionStream(desiredOutputFormat, reader)) {
    WaveFileWriter.CreateWaveFile("pcm.wav", converter);
}

这将导致ACMERR_NOTPOSSIBLE,因为GSM解码器只能输出8kHz 16位单声道。 

如果我们成功打开了一个ACM编解码器,现在我们需要设置一个ACMSTREAMHEADER实例,其中包含指向编解码器的输入和输出缓冲区的指针。这些作为指针传递,并且应该被固定,以免垃圾回收器移动它们,否则会导致内存损坏。您可以这样固定您的缓冲区:

sourceBuffer = new byte[sourceBufferLength];
hSourceBuffer = GCHandle.Alloc(sourceBuffer, GCHandleType.Pinned);
streamHeader.sourceBufferPointer = hSourceBuffer.AddrOfPinnedObject(); 

您声明的源和目标缓冲区需要足够大,以便进行您想要的任何转换。通常,您一次编码或解码的音频不会超过几秒钟。对于解码器,请记住目标缓冲区需要比源缓冲区大。

数据是如何通过编解码器传输的?这正在我们的WaveFileWriter.CreateWaveFile调用中发生。以下是CreateWaveFile中发生情况的简化版本: 

public static void CreateWaveFile(string filename, IWaveProvider sourceProvider)
{
    using (var writer = new WaveFileWriter(filename, sourceProvider.WaveFormat))
    {
        var buffer = new byte[sourceProvider.WaveFormat.AverageBytesPerSecond * 4];
        while (true)
        {
            int bytesRead = sourceProvider.Read(buffer, 0, buffer.Length);
            if (bytesRead == 0) break;
            writer.Write(buffer, 0, bytesRead);
        }
    }
}

我们正在从sourceProvider一次请求四秒钟的数据,并将其写入WAV文件,直到sourceProvider停止提供输入数据(真实版本中有更多代码可以防止您意外填满整个硬盘)。在这种情况下,sourceProviderWaveFormatConversionStream的一个实例,因此调用Read将执行两件事。

首先,WaveFormatConversionStream.Read必须弄清楚它需要从其“源流”(在我们的例子中是GSM WAV文件)读取多少字节。ACM编解码器应该通过实现acmStreamSize函数来帮助您进行此计算。但是,不幸的是,有些编解码器编写得很差,因此它并不总是可靠的指南。另一个考虑因素是,请求的输出字节数可能不会精确地映射到源文件中的字节数。因此,您可能会发现您最终需要转换比要求的更多的字节,并将剩余部分保留到下次。 

要将音频通过ACM编解码器传输,我们需要使用我们之前设置的ACMSTREAMHEADER。每次转换音频块时,我们都必须首先在此结构上调用acmStreamPrepareHeader(确保正确填充了源和目标缓冲区大小),并且在每次块转换后,我们调用acmStreamUnprepareHeader。当我最初开始尝试使用ACM时,我认为我可以节省时间而不必多次调用PrepareUnprepare,但我发现这并不可靠,所以现在我总是在每个块之前和之后都PrepareUnprepare

准备好您的ACMSTREAMHEADER后,您就可以进行转换了。首先,您将从源文件读取的字节复制到您的固定源缓冲区中。然后将cbSrcLengthcbSrcLengthUsed设置为您要转换的字节数。然后调用acmStreamConvert。完成之后,它将通过设置cbDestLengthUsed(它写入输出的字节数)和cbSrcLengthUsed(它转换的源字节数)字段来告诉您它做了什么。如果它没有使用您提供的所有源缓冲区,您将需要回溯输入文件并在下次调用acmStreamConvert时重新发送这些字节。如果您没有得到想要的输出字节数,您需要从输入文件读取更多数据块,然后重试。如果WaveFormatConversionStream得到的字节数比它想要的要多,它必须将其保存,为下次调用Read做准备。幸运的是,对于CBR文件,我们可以准确地预测给定输入文件数将创建多少输出字节,因此唯一的问题是请求的输出字节数是否对应于输入文件中的“块对齐”的倍数。 

使用ACM和DMO将MP3转换为WAV

理论上可以使用相同的技术。但是,由于我们的MP3文件不是WAV文件,我们需要创建一个合适的WaveFormat来请求相应的ACM编解码器。要找出WaveFormat结构中应该包含什么,您可以使用本文前面所述的枚举已安装编解码器支持的格式的代码。事实证明,它是MPEGLAYER3WAVEFORMAT结构的一个实例。以下是Как я реализую эту структуру в C# (унаследовав отWaveFormat, который реализуетWAVEFORMATEX): 

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi, Pack = 2)]
public class Mp3WaveFormat : WaveFormat
{
    public Mp3WaveFormatId id;
    public Mp3WaveFormatFlags flags;
    public ushort blockSize;
    public ushort framesPerBlock;
    public ushort codecDelay;
    private const short Mp3WaveFormatExtraBytes = 12; // MPEGLAYER3_WFX_EXTRA_BYTES
    public Mp3WaveFormat(int sampleRate, int channels, int blockSize, int bitRate)
    {
        waveFormatTag = WaveFormatEncoding.MpegLayer3;
        this.channels = (short)channels;
        this.averageBytesPerSecond = bitRate / 8;
        this.bitsPerSample = 0; // must be zero
        this.blockAlign = 1; // must be 1
        this.sampleRate = sampleRate;

        this.extraSize = Mp3WaveFormatExtraBytes;
        this.id = Mp3WaveFormatId.Mpeg;
        this.flags = Mp3WaveFormatFlags.PaddingIso;
        this.blockSize = (ushort)blockSize;
        this.framesPerBlock = 1;
        this.codecDelay = 0;
    }
}

但这会给我们带来另一个问题。我们如何从MP3文件中找出采样率、声道数等信息?没有这些信息,我们就无法知道它会转换成什么PCM格式。因此,NAudio对MP3文件采取了稍微不同的方法,并引入了Mp3FileReader类。 

MP3FileReader的第一个任务是找到第一个MP3帧头。MP3文件主要由一系列MP3“帧”组成,这些帧是可单独转换为PCM的数据块。但是,它们也可以包含ID3或ID3v2标签,其中包含MP3文件的元数据,如标题、艺术家和专辑名称。大多数MP3编解码器允许您输入ID3帧,但它们当然不会解码为任何音频。为了使MP3解码器能够执行任何操作,它至少需要一个完整的帧在其输入缓冲区中。 

还值得注意的是,可变比特率MP3文件可以在开头包含特殊的'XING'或'VBRI'头——您能找到的最佳指南是CodeProject上的这篇文章。但是,一旦找到第一个MP3帧,我们就可以使用它来查找采样率和声道数,这很重要,因为它决定了我们将解码成的PCM格式(比特数始终为16)。 

您可以通过简单地将文件块传递给ACM解码器来解码MP3,并且只要它包含至少一个帧,就应该会产生一些输出。任何剩余的源字节显然是下一帧(或者可能不是MP3数据)。然而,由于NAudio知道如何解析MP3帧,我们可以一次将它们发送给ACM解码器。MP3FileReader需要一个IMp3FrameDecompressor的实例,但默认情况下它使用AcmMp3FrameDecompressorIMp3FrameDecompressor接口看起来像这样: 

public interface IMp3FrameDecompressor : IDisposable
{
    int DecompressFrame(Mp3Frame frame, byte[] dest, int destOffset);
    void Reset();
    WaveFormat OutputFormat { get; }
}

DecompressFrame方法是最重要的部分,因为它将单个Mp3Frame解压缩到PCM并将其写入目标缓冲区,返回写入的字节数。以下是AcmMp3FrameDecompressor的一个略微编辑的版本。它使用NAudio的AcmStream来处理准备和取消准备我们标题: 

public class AcmMp3FrameDecompressor : IMp3FrameDecompressor
{
    private readonly AcmStream conversionStream;
    private readonly WaveFormat pcmFormat;

    public AcmMp3FrameDecompressor(WaveFormat sourceFormat)
    {
        this.pcmFormat = AcmStream.SuggestPcmFormat(sourceFormat);
        conversionStream = new AcmStream(sourceFormat, pcmFormat);
    }

    public WaveFormat OutputFormat { get { return pcmFormat; } }

    public int DecompressFrame(Mp3Frame frame, byte[] dest, int destOffset)
    {
        Array.Copy(frame.RawData, conversionStream.SourceBuffer, frame.FrameLength);
        int sourceBytesConverted = 0;
        int converted = conversionStream.Convert(frame.FrameLength, out sourceBytesConverted);
        if (sourceBytesConverted != frame.FrameLength)
        {
            throw new InvalidOperationException(String.Format(
                "Couldn't convert the whole MP3 frame (converted {0}/{1})",
                sourceBytesConverted, frame.FrameLength));
        }
        Array.Copy(conversionStream.DestBuffer, 0, dest, destOffset, converted);
        return converted;
    }
    
    // ... more reset and dispose stuff
}

正如您所看到的,我们要求ACM编解码器提供最合适的PCM格式进行解码。然后,当我们解压缩一个帧时,我们将整个帧(包括其四字节头)复制到转换流的源缓冲区中。由于我们知道我们传入的是一个完整的帧,我们期望编解码器告诉我们它转换了所有的源字节。转换后,我们将数据从固定的目标缓冲区复制到调用者希望我们使用的缓冲区。 

拥有IMp3FrameDecompressor的优点在于它允许使用替代策略来解压缩MP3帧。例如,NAudio还包含一个基于DMO的MP3帧解码器,可以替代使用。这是DmoMp3FrameDecompressor的基本实现,它让您了解如何使用NAudio DMO对象包装器来处理DirectX Media Objects。 

public class DmoMp3FrameDecompressor : IMp3FrameDecompressor
{
    private WindowsMediaMp3Decoder mp3Decoder;
    private WaveFormat pcmFormat;
    private MediaBuffer inputMediaBuffer;
    private DmoOutputDataBuffer outputBuffer;
    private bool reposition;

    public DmoMp3FrameDecompressor(WaveFormat sourceFormat)
    {
        this.mp3Decoder = new WindowsMediaMp3Decoder();
        if (!mp3Decoder.MediaObject.SupportsInputWaveFormat(0, sourceFormat))
        {
            throw new ArgumentException("Unsupported input format");
        }
        mp3Decoder.MediaObject.SetInputWaveFormat(0, sourceFormat);
        pcmFormat = new WaveFormat(sourceFormat.SampleRate, sourceFormat.Channels); // 16 bit
        if (!mp3Decoder.MediaObject.SupportsOutputWaveFormat(0, pcmFormat))
        {
            throw new ArgumentException(String.Format("Unsupported output format {0}", pcmFormat));
        }
        mp3Decoder.MediaObject.SetOutputWaveFormat(0, pcmFormat);

        // a second is more than enough to decompress a frame at a time
        inputMediaBuffer = new MediaBuffer(sourceFormat.AverageBytesPerSecond);
        outputBuffer = new DmoOutputDataBuffer(pcmFormat.AverageBytesPerSecond);
    }

    public WaveFormat OutputFormat { get { return pcmFormat; } }

    public int DecompressFrame(Mp3Frame frame, byte[] dest, int destOffset)
    {
        // 1. copy into our DMO's input buffer
        inputMediaBuffer.LoadData(frame.RawData, frame.FrameLength);

        if (reposition)
        {
            mp3Decoder.MediaObject.Flush();
            reposition = false;
        }

        // 2. Give the input buffer to the DMO to process
        mp3Decoder.MediaObject.ProcessInput(0, inputMediaBuffer, DmoInputDataBufferFlags.None, 0, 0);

        outputBuffer.MediaBuffer.SetLength(0);
        outputBuffer.StatusFlags = DmoOutputDataBufferFlags.None;

        // 3. Now ask the DMO for some output data
        mp3Decoder.MediaObject.ProcessOutput(DmoProcessOutputFlags.None, 1, new[] { outputBuffer });

        if (outputBuffer.Length == 0)
        {
            Debug.WriteLine("ResamplerDmoStream.Read: No output data available");
            return 0;
        }

        // 5. Now get the data out of the output buffer
        outputBuffer.RetrieveData(dest, destOffset);
        
        return outputBuffer.Length;
    }
    
    // ... Reset and Dispose
}

也可以通过您自己的自定义MP3帧解码器来扩展NAudio。我希望很快添加一个使用Media Foundation的。或者,您可以将其中一个使用另一个我的开源项目,NLayer。 

排除这一切之后,NAudio中将MP3转换为WAV的代码是什么?实际上非常简单:

using(var reader = new Mp3FileReader("somefile.mp3")) {
    WaveFileWriter.CreateWaveFile("pcm.wav", reader);
}

如果您想更改MP3帧解码器,您可以指定一个不同的解码器,如下所示:

using(var reader = new Mp3FileReader("somefile.mp3", (wf) => new DmoMp3FrameDecompressor(wf)))
{
    WaveFileWriter.CreateWaveFile("pcm.wav", reader);
}

链接ACM编解码器以执行多步转换

我已经解释过,解码器通常只支持一种PCM格式作为其解码器输出格式。但是,如果您想要不同的采样率怎么办?好事是ACM包含一个重采样器,所以您可以将一个链接到另一个。让我们展示两个简单的例子。首先,让我们解码一些a-law(8kHz),然后上采样到16kHz: 

using(var reader = new WaveFileReader("alaw.wav"))
using(var converter = WaveFormatConversionStream.CreatePcmStream(reader)) 
using(var upsampler = new WaveFormatConversionStream(new WaveFormat(16000, converter.WaveFormat.Channels), converter)) 
{
    WaveFileWriter.CreateWaveFile("pcm16000.wav", upsampler);
}

我们可以做类似的事情来将MP3文件降采样到32kHz。请记住,Mp3FileReader已经输出PCM,可能是44.1kHz立体声。所以我们可以连接另一个WaveFormatConversionStream来执行重采样步骤。 

using(var reader = new Mp3FileReader("example.mp3"))
using(var downsampler = new WaveFormatConversionStream(new WaveFormat(32000, reader.WaveFormat.Channels), reader)) 
{
    WaveFileWriter.CreateWaveFile("pcm32000.wav", downsampler);
}

使用ACM编码为GSM

在离开ACM去讨论Media Foundation之前,让我们简要看一下如何使用ACM编码文件。实际上它与解码非常相似,但主要区别在于您必须预先知道编码器想要的精确WaveFormat结构。例如,我知道GSM编码器需要一个包含两个额外字节的WAVEFORMATEX,代表每块的样本数。我创建了一个派生的WaveFormat类,它设置了13kbps GSM 610的适当值,并填充了每块样本数的值: 

[StructLayout(LayoutKind.Sequential, Pack = 2)]
public class Gsm610WaveFormat : WaveFormat
{
    private short samplesPerBlock;

    public Gsm610WaveFormat()
    {
        this.waveFormatTag = WaveFormatEncoding.Gsm610;
        this.channels = 1;
        this.averageBytesPerSecond = 1625; // 13kbps
        this.bitsPerSample = 0; // must be zero
        this.blockAlign = 65;
        this.sampleRate = 8000;

        this.extraSize = 2;
        this.samplesPerBlock = 320;
    }

    public short SamplesPerBlock { get { return this.samplesPerBlock; } }
} 

值得重申的是,您不需要理解结构中的所有值是什么,因为编解码器本身能够告诉您它想要什么值(有关更多信息,请参阅有关枚举ACM编解码器的部分)。有了这个自定义WaveFormat,我们现在就可以非常简单地完成从PCM到GSM的转换: 

using(var reader = new WaveFileReader("pcm.wav"))
using(var converter = new WaveFormatConversionStream(new Gsm610WaveFormat(), reader)) {
    WaveFileWriter.CreateWaveFile("gsm.wav", converter);
} 

请记住,编码器可能只接受一种PCM格式作为有效输入(在本例中为8kHz、16位、单声道)。但是使用这种技术,您应该能够使用您计算机上安装的任何ACM编码器。

使用Media Foundation将WMA、MP3或AAC转换为WAV

到目前为止,我一直专注于解释如何使用ACM,但在引言中我说过,Windows平台上编解码器API的未来是Media Foundation API。那么我们如何用Media Foundation解码文件呢?Media Foundation具有“源读取器”的概念,它允许它处理读取许多不同类型的文件。换句话说,NAudio中的WavFileReaderMp3FileReader等类可能实际上不是必需的(尽管它们仍然提供一些其他好处,例如从WAV文件中读取自定义块,或通过丢弃开头或结尾的帧来修剪MP3文件)。

首先,我们必须创建我们的源读取器。为此,我们使用MFCreateSourceReaderFromURL函数。顾名思义,它实际上可以用于流式传输带有http://或mms://URL的音频,但如果我们从本地磁盘播放文件,我们可以使用file://URL或直接传递路径。

IMFSourceReader pReader;
MediaFoundationInterop.MFCreateSourceReaderFromURL(fileName, null, out pReader);

这给了我们一个IMFSourceReader接口的一个实例。在使用它之前,我们需要先做几件事。首先,我们“选择”文件中的第一个音频流。这是因为IMFSourceReader也处理视频文件,所以实际上可能不止一个音频流,还有视频流。最简单的方法是取消选择所有内容,然后仅选择第一个音频流。 

pReader.SetStreamSelection(MediaFoundationInterop.MF_SOURCE_READER_ALL_STREAMS, false);
pReader.SetStreamSelection(MediaFoundationInterop.MF_SOURCE_READER_FIRST_AUDIO_STREAM, true);

现在我们需要告诉源读取器我们想要PCM。这是一个非常好的功能,这意味着源读取器本身将尝试查找适当的编解码器将音频解码为PCM。我们通过调用SetCurrentMediaType来做到这一点,并使用一个“部分”媒体类型对象,该对象仅指定我们想要PCM。 

IMFMediaType partialMediaType;
MediaFoundationInterop.MFCreateMediaType(out partialMediaType);
partialMediaType.SetGUID(MediaFoundationAttributes.MF_MT_MAJOR_TYPE, MediaTypes.MFMediaType_Audio);
partialMediaType.SetGUID(MediaFoundationAttributes.MF_MT_SUBTYPE, AudioSubtypes.MFAudioFormat_PCM);
pReader.SetCurrentMediaType(MediaFoundationInterop.MF_SOURCE_READER_FIRST_AUDIO_STREAM, IntPtr.Zero, partialMediaType);
Marshal.ReleaseComObject(partialMediaType);

假设这成功了,我们现在可以调用GetCurrentMediaType来找出流将被解码成的确切PCM格式。 NAudio使用它来生成一个WaveFormat对象,代表这个MediaFoundationReader的解压缩格式。

IMFMediaType uncompressedMediaType;
pReader.GetCurrentMediaType(MediaFoundationInterop.MF_SOURCE_READER_FIRST_AUDIO_STREAM, 
        out uncompressedMediaType); 
Guid audioSubType;
uncompressedMediaType.GetGUID(MediaFoundationAttributes.MF_MT_SUBTYPE, out audioSubType);
Debug.Assert(audioSubType == AudioSubtypes.MFAudioFormat_PCM);
int channels;
uncompressedMediaType.GetUINT32(MediaFoundationAttributes.MF_MT_AUDIO_NUM_CHANNELS, out channels);
int bits;
uncompressedMediaType.GetUINT32(MediaFoundationAttributes.MF_MT_AUDIO_BITS_PER_SAMPLE, out bits);
int sampleRate;
uncompressedMediaType.GetUINT32(MediaFoundationAttributes.MF_MT_AUDIO_SAMPLES_PER_SECOND, out sampleRate);
waveFormat = new WaveFormat(sampleRate, bits, channels);

现在我们已经设置好了源读取器,我们就可以开始解码音频了。基本技术是不断调用IMFSourceReader接口上的 ReadSample。尽管有这个名字,但这并不意味着读取单个PCM样本。相反,它从源文件中读取下一个压缩数据块,并将其作为包含PCM的样本返回。Media Foundation实际上以100ns为单位为所有样本加时间戳。但是,由于我们知道每个样本直接跟在音频中的前一个样本后面,所以我们不需要使用时间戳。这是读取下一个样本的方法:

IMFSample pSample;
int dwFlags;
ulong timestamp;
int actualStreamIndex;
pReader.ReadSample(MediaFoundationInterop.MF_SOURCE_READER_FIRST_AUDIO_STREAM, 0, out actualStreamIndex, out dwFlags, out timestamp, out pSample);
if (dwFlags != 0) // reached the end of the stream or media type changed
{
    return;
} 

现在我们已经读取了一个样本,直到将其转换为IMFMediaBuffer,我们实际上无法访问其原始PCM数据。调用ConvertToContiguousBuffer会做到这一点,直到我们调用该缓冲区的Lock,我们才能获得包含原始PCM样本的实际内存指针,然后我们可以将其复制到我们自己的字节数组中。 

IMFMediaBuffer pBuffer;
pSample.ConvertToContiguousBuffer(out pBuffer);
IntPtr pAudioData = IntPtr.Zero;
int cbBuffer;
int pcbMaxLength;
pBuffer.Lock(out pAudioData, out pcbMaxLength, out cbBuffer);
var decoderOutputBuffer = new byte[cbBuffer];
Marshal.Copy(pAudioData, decoderOutputBuffer, 0, cbBuffer);
pBuffer.Unlock();
Marshal.ReleaseComObject(pBuffer);
Marshal.ReleaseComObject(pSample);

与往常一样,NAudio试图阻止您自己编写所有这些互操作代码,因此您可以使用以下简单的代码将WMA(或MP3或AAC或其他)转换为PCM(注意:这在NAudio 1.7中是新功能,目前处于预发布状态): 

using(var reader = new MediaFoundationReader("myfile.wma"))
{
    WaveFileWriter.CreateWaveFile("myfile.wav", reader);
}

使用Media Foundation从视频文件中提取音频

Media Foundation API的通用性质在只处理音频时可能会引起一些挫败感,但它确实带来了相当大的好处。其中一个好处是,从视频文件中提取音频变得非常容易。以下是如何将.m4v视频文件的配乐保存到WAV:  

using(var reader = new MediaFoundationReader("movie.m4v"))
{
    WaveFileWriter.CreateWaveFile("soundtrack.wav", reader);
}   

使用Media Foundation将PCM编码为WMA、MP3或AAC

所以我们已经看到使用Media Foundation解码音频非常容易。那么编码呢?这有点复杂,因为(与ACM一样),您需要就您想编码的确切编码格式做出一些决定。 

通常,当您编写支持编码的应用程序时,您希望向用户提供编码格式的选择。为此,您需要能够查询系统中已有哪些编码器。但是,如果您这样做,您可能会显示一些不适合您应用程序的编码器。因此,最好有一个音频子类型的短名单(例如,MP3、WMA和AAC),然后询问Media Foundation这些类型的可用比特率。 

我们通过调用MFTranscodeGetAudioOutputAvailableTypes来实现这一点。这将允许我们获得一个IMFMediaType对象列表,这些对象代表该类型编码器可以创建的所有可能格式。这通常会返回一个非常长的列表,因此需要对其进行过滤。首先,您应该只选择与输入采样率和声道数匹配的格式。完成此操作后,剩余媒体格式之间的主要区别应该是不同的比特率。但是,有时还有其他可配置参数。例如,Windows 8中的AAC编码器允许您选择MF_MT_AAC_PAYLOAD_TYPE。这是一个查找给定输入文件特定采样率和声道数的编码类型(例如,WMA、MP3、AAC)的可能编码比特率的函数:

public static int[] GetEncodeBitrates(Guid audioSubtype, int sampleRate, int channels)
{
    var bitRates = new HashSet<int>();
    IMFCollection availableTypes;
    MediaFoundationInterop.MFTranscodeGetAudioOutputAvailableTypes(
        audioSubtype, _MFT_ENUM_FLAG.MFT_ENUM_FLAG_ALL, null, out availableTypes);
    int count;
    availableTypes.GetElementCount(out count);
    for (int n = 0; n < count; n++)
    {
        object mediaTypeObject;
        availableTypes.GetElement(n, out mediaTypeObject);
        var mediaType = (IMFMediaType)mediaTypeObject;

        // filter out types that are for the wrong sample rate and channels
        int samplesPerSecond;
        mediaType.GetUINT32(MediaFoundationAttributes.MF_MT_AUDIO_SAMPLES_PER_SECOND, out samplesPerSecond);
        if (sampleRate != samplesPerSecond)
            continue;
        int channelCount;
        mediaType.GetUINT32(MediaFoundationAttributes.MF_MT_AUDIO_NUM_CHANNELS, out channelCount);
        if (channels != channelCount)
            continue;

        int bytesPerSecond;
        mediaType.GetUINT32(MediaFoundationAttributes.MF_MT_AUDIO_AVG_BYTES_PER_SECOND, out bytesPerSecond);
        bitRates.Add(bytesPerSecond*8);
        Marshal.ReleaseComObject(mediaType);
    }
    Marshal.ReleaseComObject(availableTypes);
    return bitRates.ToArray();
}

一旦我们决定了要使用的确切比特率,我们就可以开始编码了。 我们通过创建一个“汇写入器”开始。汇写入器是Media Foundation提供了解如何写入各种容器类型的类的途径。Media Foundation支持的容器类型显示在此列表中。您可以以各种方式创建汇写入器,但最简单的方法是调用MFCreateSinkWriterFromURL。它接受一个属性参数,您可以用它来指定所需的容器类型,但它也可以从文件名中推断出来。但是,您应该记住,不同版本的Windows支持不同的容器类型,因此在Windows 8中,您可以创建.aac文件,但如果您使用的是Windows 7,则必须将AAC音频放入.mp4容器中。与MFCreateSourceReaderFromURL不同,您不能以file://格式提供路径。这个需要一个常规文件路径。 

创建汇写入器后,您需要配置其输入和输出媒体类型。输出媒体类型应该是MFTranscodeGetAudioOutputAvailableTypes返回的IMFMediaType对象之一。输入类型将是PCM媒体类型,您可以自行配置,或使用MFInitMediaTypeFromWaveFormatEx创建。如果您请求的编码不可能实现,您将在此处获得MF_E_INVALIDMEDIATYPE。最后,您调用汇写入器的BeginWriting

int streamIndex;
writer.AddStream(outputMediaType, out streamIndex);
writer.SetInputMediaType(streamIndex, inputMediaType, null);
writer.BeginWriting(); 

现在我们准备好处理输入文件并对其进行编码了。基本过程是调用输入文件的IMFSample,用输入文件中的数据填充它,然后将其传递给汇写入器的WriteSample函数。但是,配置样本对象有点棘手。 

您可以很容易地通过调用MFCreateSample来创建一个IMFSample。但每个样本都需要包含至少一个IMFMediaBuffer。由于我们想直接将PCM写入此缓冲区,因此我们使用MFCreateMemoryBuffer来创建它。您可以指定缓冲区的字节大小。我倾向于一次处理一秒钟的编码块,所以这个缓冲区需要与输入文件PCM WaveFormat的每秒平均字节数相同。 

现在为了能够将数据从源文件复制到缓冲区,我们使用相同的技术,即调用IMFMediaBuffer上的Lock,执行从源文件中复制一秒钟PCM到Lock返回的指针的操作。然后我们Unlock缓冲区,并通过调用SetCurrentLength告诉它我们写入了多少字节。 

编码时要做的另一件重要事情是调用IMFSample上的SetSampleTimeSetSampleDuration。这很烦人,因为我们真的只想让编码器假定我们提供的所有样本都紧跟在最后一个样本之后。这个功能可能更适用于视频编码,您想为单个帧添加时间戳。Media Foundation使用的时戳和持续时间单位是100ns。 所以我们可以使用这个函数从字节数转换为正确的单位:

private static long BytesToNsPosition(int bytes, WaveFormat waveFormat)
{
    long nsPosition = (10000000L * bytes) / waveFormat.AverageBytesPerSecond;
    return nsPosition;
}

设置了媒体缓冲区和样本后,我们就可以使用WriteSample将其传递给编码器。您也可以在此处调用Flush。您可能会认为刷新写入器后,可以节省内存并重用媒体缓冲区和样本,但我的测试表明这样做会导致编码损坏。因此,最好每次都简单地创建新的缓冲区和样本。 以下是NAudio用于将单个缓冲区传递给编码器的代码: 

private long ConvertOneBuffer(IMFSinkWriter writer, int streamIndex, IWaveProvider inputProvider, long position, byte[] managedBuffer)
{
    long durationConverted = 0;
    int maxLength;
    IMFMediaBuffer buffer = MediaFoundationApi.CreateMemoryBuffer(managedBuffer.Length);
    buffer.GetMaxLength(out maxLength);

    IMFSample sample = MediaFoundationApi.CreateSample();
    sample.AddBuffer(buffer);

    IntPtr ptr;
    int currentLength;
    buffer.Lock(out ptr, out maxLength, out currentLength);
    int read = inputProvider.Read(managedBuffer, 0, maxLength);
    if (read > 0)
    {
        durationConverted = BytesToNsPosition(read, inputProvider.WaveFormat);
        Marshal.Copy(managedBuffer, 0, ptr, read);
        buffer.SetCurrentLength(read);
        buffer.Unlock();
        sample.SetSampleTime(position);
        sample.SetSampleDuration(durationConverted);
        writer.WriteSample(streamIndex, sample);
        //writer.Flush(streamIndex);
    }
    else
    {
        buffer.Unlock();
    }

    Marshal.ReleaseComObject(sample);
    Marshal.ReleaseComObject(buffer);
    return durationConverted;
}

如果所有这些看起来工作量很大,那么好消息是NAudio提供了一个简化的接口。MediaFoundationEncoder类(同样是NAudio 1.7的新增功能)允许您非常简单地执行编码。EncodeToWmaEncodeToMp3EncodeToAac辅助方法接受一个输入IWaveProvider(NAudio中的几乎所有东西都是IWaveProvider,所以它可以是一个文件读取器,或者您以其他方式生成的音频数据流)、一个输出文件名和一个所需的比特率。

using(var reader = MediaFoundationReader("somefile.mp3"))
{
    MediaFoundationEncoder.EncodeToWma(reader, "encoded.wma", 192000);
}  

或者,如果您愿意,可以使用MediaFoundationEncoder.GetOutputMediaTypes来请求特定音频子类型的可用媒体类型。例如,如果您要编码AAC,您可能希望让用户从可能的编码列表中选择,然后将其传递给MediaFoundationEncoder构造函数。 

var outputTypes = MediaFoundationEncoder.GetOutputMediaTypes(
    AudioSubtypes.MFAudioFormat_AAC;
var mediaType = // TODO: select one
using(var reader = MediaFoundationReader("somefile.mp4"))
using(var encoder = MediaFoundationEncoder(mediaType))
{
    encoder.Encode("outFile.mp4", reader);
} 

使用Media Foundation重采样 

我最后想与 Media Foundation 讨论的是 Resampler 对象。要使用此对象,我们必须直接访问 IMFTransform 接口,因为我们不会使用 Source Readers 或 Sink Writers。在 .NET 中正确地通过 IMFTransform 传递数据是 Media Foundation 中最难实现的任务之一。好消息是,一旦你完成了这项工作,你就可以将其用于所有 MFT,包括编码器和解码器,例如,你可以支持从网络流解码或编码到网络流,而不是文件。

首先要做的就是创建实现 IMFTransform 接口的 COM 对象。你可以通过几种方式来实现这一点。一种方法是使用你在枚举 Media Foundation Transforms 时获得的 IMFActivate 接口上的 ActivateObject 调用。如果你正在编写 Windows 应用商店应用程序,则必须使用此技术。对于其他应用程序类型,如果你知道要创建的特定转换的 GUID(就像我们对 Resampler 所做的那样),你可以使用 ComImport 属性来创建一个:  

[ComImport, Guid("f447b69e-1884-4a7e-8055-346f74d6edb3")]
class ResamplerMediaComObject
{
} 

当你创建此类的一个实例时,你将获得一个包装 COM 对象的包装器,允许你将其转换为该 COM 对象支持的任何接口。因此,我们首先将其转换为 IMFTransform,然后需要设置其输入和输出类型。对于 resampler,两者都将是 PCM,在 NAudio 中,最简单的方法是从现有的 WaveFormat 结构构造它们:  

var comObject = new ResamplerMediaComObject();
resamplerTransform = (IMFTransform) comObject;

var inputMediaFormat = MediaFoundationApi.CreateMediaTypeFromWaveFormat(sourceProvider.WaveFormat);
resamplerTransform.SetInputType(0, inputMediaFormat, 0);
Marshal.ReleaseComObject(inputMediaFormat);

var outputMediaFormat = MediaFoundationApi.CreateMediaTypeFromWaveFormat(waveFormat);
resamplerTransform.SetOutputType(0, outputMediaFormat, 0);
Marshal.ReleaseComObject(outputMediaFormat); 

我们还有机会为 Resampler 设置其质量,这是一个介于 1 和 60 之间的数字。1 是最低质量(线性插值),60 是最高质量。质量越高,引入的延迟就越大(即,你需要读取更多输入样本才能出现任何输出样本)。我没有进行任何性能测量来查看需要多少额外的 CPU 使用率,但 resampler 在最高质量下似乎性能足够好。你可以使用 IWMResamplerProps 接口设置该属性,该接口由同一个 COM 对象实现: 

var resamplerProps = (IWMResamplerProps) comObject;
// 60 is the best quality, 1 is linear interpolation
resamplerProps.SetHalfFilterLength(60);

在我们可以开始通过转换传递数据之前的最后一步是向其发送一些初始化消息

resamplerTransform.ProcessMessage(MFT_MESSAGE_TYPE.MFT_MESSAGE_COMMAND_FLUSH, IntPtr.Zero);
resamplerTransform.ProcessMessage(MFT_MESSAGE_TYPE.MFT_MESSAGE_NOTIFY_BEGIN_STREAMING, IntPtr.Zero);
resamplerTransform.ProcessMessage(MFT_MESSAGE_TYPE.MFT_MESSAGE_NOTIFY_START_OF_STREAM, IntPtr.Zero); 

现在要通过转换传递音频,我们必须执行以下步骤

  • 从源文件中读取一些 PCM 数据,并使用它来创建一个包含内存缓冲区的样本
  • 调用转换上的 ProcessInput,传入样本
  • 调用转换上的 ProcessOutput,并将数据从返回的样本对象中复制出来

前两个步骤与我们在向接收写入器提供数据时需要做的非常相似,而第三个步骤与我们从源读取器读取时所做的非常相似,因此代码现在应该看起来很熟悉了。以下是我们创建 IMFSample 的一些代码: 

private IMFSample ReadFromSource()
{
    // we always read a full second
    int bytesRead = sourceProvider.Read(sourceBuffer, 0, sourceBuffer.Length);
    if (bytesRead == 0) return null;

    var mediaBuffer = MediaFoundationApi.CreateMemoryBuffer(bytesRead);
    IntPtr pBuffer;
    int maxLength, currentLength;
    mediaBuffer.Lock(out pBuffer, out maxLength, out currentLength);
    Marshal.Copy(sourceBuffer, 0, pBuffer, bytesRead);
    mediaBuffer.Unlock();
    mediaBuffer.SetCurrentLength(bytesRead);

    var sample = MediaFoundationApi.CreateSample();
    sample.AddBuffer(mediaBuffer);
    // we'll set the time, I don't think it is needed for Resampler, but other MFTs might need it
    sample.SetSampleTime(inputPosition);
    long duration = BytesToNsPosition(bytesRead, sourceProvider.WaveFormat);
    sample.SetSampleDuration(duration);
    inputPosition += duration;
    Marshal.ReleaseComObject(mediaBuffer);
    return sample;
}

创建 IMFSample 后,将其传递给转换

transform.ProcessInput(0, sample, 0); 

然后我们可以从样本中读取数据并将其写入我们的托管输出缓冲区(此处称为 outputBuffer

private int ReadFromTransform()
{
    var outputDataBuffer = new MFT_OUTPUT_DATA_BUFFER[1];
    // we have to create our own for
    var sample = MediaFoundationApi.CreateSample();
    var pBuffer = MediaFoundationApi.CreateMemoryBuffer(outputBuffer.Length);
    sample.AddBuffer(pBuffer);
    sample.SetSampleTime(outputPosition); // hopefully this is not needed
    outputDataBuffer[0].pSample = sample;
    
    _MFT_PROCESS_OUTPUT_STATUS status;
    var hr = transform.ProcessOutput(_MFT_PROCESS_OUTPUT_FLAGS.None, 
                                     1, outputDataBuffer, out status);
    if (hr == MediaFoundationErrors.MF_E_TRANSFORM_NEED_MORE_INPUT)
    {
        Marshal.ReleaseComObject(pBuffer);
        Marshal.ReleaseComObject(sample);
        // nothing to read
        return 0;
    }
    else if (hr != 0)
    {
        Marshal.ThrowExceptionForHR(hr);
    }

    IMFMediaBuffer outputMediaBuffer;
    outputDataBuffer[0].pSample.ConvertToContiguousBuffer(out outputMediaBuffer);
    IntPtr pOutputBuffer;
    int outputBufferLength;
    int maxSize;
    outputMediaBuffer.Lock(out pOutputBuffer, out maxSize, out outputBufferLength);
    outputBuffer = BufferHelpers.Ensure(outputBuffer, outputBufferLength);
    Marshal.Copy(pOutputBuffer, outputBuffer, 0, outputBufferLength);
    outputBufferOffset = 0;
    outputBufferCount = outputBufferLength;
    outputMediaBuffer.Unlock();
    outputPosition += BytesToNsPosition(outputBufferCount, WaveFormat); // hopefully not needed
    Marshal.ReleaseComObject(pBuffer);
    Marshal.ReleaseComObject(sample);
    Marshal.ReleaseComObject(outputMediaBuffer);
    return outputBufferLength;
}

同样,你可能认为仅仅为了让音频通过 resampler 就需要编写大量的代码,这太荒谬了。我已经努力让 NAudio 尽可能简单。以下是使用 NAudio 将 MP3 文件重新采样到 16kHz 的代码:  

using (var reader = new MediaFoundationReader("input.mp3"))
using (var resampler = new MediaFoundationResampler(reader, 16000))
{
    WaveFileWriter.CreateWaveFile("output resampled 16kHz.wav", resampler);
}  

简洁明了。MediaFoundationResampler 类还有一个重载,接受 WaveFormat 参数,允许你利用其更改位深度或通道数的能力。让我们将同一个 MP3 文件转换为浮点文件,使用 resampler:  

using (var reader = new MediaFoundationReader("input.mp3"))
using (var resampler = new MediaFoundationResampler(reader, 
  WaveFormat.CreateIeeeFloatWaveFormat(reader.WaveFormat.SampleRate, reader.WaveFormat.Channels)))
{
    WaveFileWriter.CreateWaveFile("output IEEE float.wav", resampler);
}

更改位深度和通道数 

我们已经展示了更改位深度和通道数所涉及的算法,虽然 Media Foundation Resampler 能够做到这一点,但有时在代码中直接进行可能会更简单,特别是如果你针对的是不支持 Media Foundation 的操作系统。NAudio 包含各种辅助类,你可以将它们链接在一起以在位深度之间移动并更改通道数。 

以下是一些可用于操作通道数的可用类

  • MonoToStereoProvider16 将 16 位单声道音频转换为立体声,允许你调整每个通道的音量。 
  • StereoToMonoProvider16 让你反向操作,选择左声道或右声道,甚至混合不同比例的两者。 
  • MultiplexingSampleProvider MultiplexingWaveProvider 功能更强大,允许你交换左右声道,将同一输入复制到多个声道等。在此处阅读有关这些类的更多信息。 

还有许多用于操作位深度的类。我将仅提及一些更常用的类

  • Wave16ToFloatProvider WaveFloatTo16Provider 允许你在使用 IWaveProvider 接口时(该接口在字节数组中传递音频数据)在 16 位和 IEEE 浮点之间移动。 
  • 或者,NAudio 有 ISampleProvider 用于 IEEE 浮点,并在浮点数组中传递音频数据,这使得操作和检查样本更加容易。Pcm16BitToSampleProviderPcm24BitToSampleProvider 可以将 PCM 转换为 IEEE 浮点,然后使用 SampleToWaveProvider16 将数据转换回 16 位格式,准备写入 WAV 文件。

无需 ACM 或 MFT 即可转换音频 

有时你需要转换到或从 ACM 或 MFT 未涵盖的音频编解码器。或者,你的应用程序需要支持 Windows XP,而你想要的 MFT 不可用。在这种情况下,你有三种主要的音频转换选项。

首先,最简单的方法是捆绑一个命令行音频转换工具与你的应用程序。有许多优秀的开源音频转换程序,例如sox。另一个很好的例子是lame.exe,它可以编码 MP3。如果你创建一个 WAV 文件,你可以使用 lame.exe 以 128kbps 的速度将其转换为 WAV,如下所示: 

var infile = @"C:\Users\Mark\Desktop\Temp\somefile.wav";
var outfile = @"c:\users\mark\Desktop\Temp\encoded128.mp3";
var lamepath = @"C:\Users\Mark\Apps\lame.exe";
Process p = new Process();
p.StartInfo.FileName = lamepath; 
p.StartInfo.UseShellExecute = false;
p.StartInfo.Arguments = String.Format("-b 128 \"{0}\" \"{1}\"", infile, outfile);
p.StartInfo.CreateNoWindow = true;
p.Start(); 
一些命令行程序甚至允许你从其 stdin 和 stdout 流式传输数据。例如,你可以使用 lame.exe 在不进行中间 WAV 文件创建步骤的情况下即时编码 MP3 文件。以下是如何直接将 16kHz 单声道 PCM 编码为 MP3 的示例(在此处阅读有关命令行开关的信息)
string outputFileName = @"c:\users\mark\documents\test.mp3";
Process p = new Process();
p.StartInfo.FileName = @"lame.exe"; 
p.StartInfo.UseShellExecute = false;
p.StartInfo.RedirectStandardInput = true;
p.StartInfo.Arguments = "-r -s 16 -m m - \"" + outputFileName + "\"";
p.StartInfo.CreateNoWindow = true;
p.Start();
// now write your raw PCM audio into the standard input stream and close it when you are done
p.StandardInput.BaseStream;

你的第二个选择是将编解码器移植到托管代码。取决于编解码器,这可能是一项艰巨的任务。NAudio 包含 G.711(MuLawDecoderMuLawEncoderALawDecoderALawEncoder)和 G.722(G722Codec)的完全托管实现。我还移植了一个 MP3 解码器,该解码器可在NLayer项目中使用(由于开源许可证不兼容,不能成为 NAudio 的一部分)。还有一个完全托管的 Ogg Vorbis 解码器可在NVorbis 上获得。它包括一个与 NAudio 兼容的类,称为 VorbisWaveReader。  但并非所有编解码器都是开源的,因此移植到托管代码并非总是可行的选项。

第三个选择是为你自己的非托管编解码器 DLL 编写 P/Invoke 包装器。例如,Yuval Naveh 在他的PracticeSharp 开源项目中这样做,为 NAudio 添加了 FLAC 支持。编写互操作包装器可能是一个耗时且令人沮丧的过程,但通常比尝试对编解码器进行完全托管端口要快。  

性能问题 

我将简要评论性能,因为实时解码和重新采样音频非常常见,有时你还需要实时编码。现代处理器实际上能够足够快地处理大多数音频编码和解码任务,使其不再成为问题。  但是,你可以使用一些技巧来加快速度。 

一个技巧是声明你的缓冲区并在前面重用它们,以避免给垃圾回收器带来额外的工作。有时这可能意味着创建一个缓冲区池,你在其中循环使用几个缓冲区,允许多个缓冲区同时处于活动状态。但垃圾回收器仍然是最有可能导致音频播放卡顿的原因,所以如果可能,请尽量避免在解码过程中创建新对象。

另一个技巧是使用适当大小的缓冲区。我通常建议一次编码或解码大约一秒钟的音频。你尝试处理的缓冲区越小,完成的无效工作就越多。但是,如果使用过大的缓冲区,编码器或解码器有时将无法正常工作。这通常是一个折衷,因为如果你想以低延迟工作(这在音频中非常重要),你将需要使用小得多的缓冲区。

在 .NET 中包装非托管音频 API 的一个令人烦恼之处在于,你会发现自己比有必要时更频繁地在数组之间复制数据。你可以使用一些技巧来避免复制或加快复制速度。例如,Buffer.BlockCopy 允许你快速地在数组之间复制,即使是不同类型的数组(因此你可以例如将数据从 byte[] 移动到 float[])。如果 API 允许,你可以使用已固定的 byte[] 上的 GCHandle 并调用 AddrOfPinnedObject,这样 API 就可以直接访问你的字节数组,而无需额外的复制步骤。

最后,在互操作签名本身方面,也有一些方法可以提高速度。例如,如果你理解并满意其后果,你可以应用SuppressUnmanagedCodeSecurity 属性。

总结 

在 .NET 应用程序中处理压缩音频格式有时会令人沮丧,但希望本文能为你提供足够的信息,以便你能够在你的应用程序中添加对绝大多数常用编解码器的读取和写入支持。

代码在哪里?   

本文中共享的所有代码(除了一些独立示例外)都是 NAudio 项目的一部分。主要的 NAudio dll 包含所有 P/Invoke 和 COM 互操作包装器以及大量辅助类。此外,NAudio 还附带两个演示项目,一个称为 NAudioDemo,另一个称为 NAudioWpfDemo。它们包含如何枚举 ACM 和 MFT 编解码器,如何使用 Media Foundation 以任何格式编码和解码音频的示例。Media Foundation 互操作是 NAudio 库的一个相对较新的补充,因此在未来几个月内该接口可能会发生一些变化。 

主库和示例项目的所有代码都可以通过转到 NAudio 项目页面的源代码管理选项卡来下载。在该页面上,你可以单击下载链接来获取最新的代码。或者,你可以下载最后一个官方版本(1.6)的源代码,但这不包含任何 Media Foundation 支持。 

以下是 NAudio 演示项目中与编码和解码文件相关的部分。首先,在 NAudioDemo 项目中,你可以使用此屏幕查看所有已安装 ACM 编解码器的详细信息,并执行 WAV 文件的编码和解码:  


WPF 演示应用程序有一个屏幕,可以让你枚举 Media Foundation Transforms


另一个屏幕允许你使用编码器创建 WMA、MP3 或 AAC 文件(取决于你系统上已安装的编码器): 

还有一个可以让你尝试 Media Foundation Resampler 的屏幕

历史  

我将尽力保持本文档的更新,包括我学到的任何新的音频编码技巧以及 NAudio 的任何 API 更新。如果你发现任何错误,也请在评论中告诉我。

  • 第一个版本,2012 年 12 月 1 日。
  • 小幅修正,2012 年 12 月 11 日,2013 年 1 月 4 日 
© . All rights reserved.