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

Managed DirectX 中的声音实验

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.85/5 (45投票s)

2006年2月27日

26分钟阅读

viewsIcon

284508

downloadIcon

4020

在托管DirectX中使用静态和流式声音缓冲区。

Sample Image

引言

我真的很喜欢C#。用C#让编译器理解我的意思所需的时间大约是C++的一半。但是由于托管DirectX是相当新的,所以很难找到从C#和.NET托管代码中使用它的好例子。

在我这篇文章的第二次修订中,我将分享一些我从自己的实验以及阅读并评论了第一版的热心读者那里学到的关于在C#代码中播放声音的知识。感谢大家!

特别是,我发现如何避免一个令人讨厌的bug,这个bug在DirectSound与某些声卡驱动程序交互时出现,它在使用默认属性分配的真实大小的缓冲区流式传输长声音时出现。(我稍后会解释这个有点笨拙的子句!)

这是我的动机:我正在使用C#开发一些新的信号转换工具,特别是声音。作为这项工作的一部分,我需要能够在.NET环境中读取长时间的声音文件,修改它们,写入它们,并听取它们。这似乎很容易,所以我搜索了一些关于使用WAVE文件并通过托管DirectX(“MDX”)DirectSound类播放它们的示例代码和文档。我发现绝大多数DirectX示例都是用非托管C++编写的(甚至是用C,在Win32 API级别工作!)。我找到了读取WAVE文件的C#示例,但它们有时会在合法文件上崩溃,并且不支持写入自己的文件。我找到了关于从静态声音缓冲区播放短声音的文章(下面和DirectX文档中有更多解释)。我找到了一篇有趣的文章,“用DirectSound构建鼓机”,作者是Ianier Munoz,它展示了在MDX中流式传输采样声音的一种方法。

2005年12月SDK附带的DirectX示例浏览器有一个示例(很方便地命名为“CaptureSound Managed”),它展示了如何将声音捕获到流式CaptureBuffer中,这是一个类似但不完全相同的问题。这两个示例都很有用,但我认为它们的方法对于我想要做的事情来说过于复杂——通过合理大小的MDX DirectSound缓冲区流式传输长声音文件。

所以我编写了自己的类,保持在.NET的托管边界内。我认为我的方法既高效又易于理解和使用。

此项目中包含:

  • 一个`RiffWaveReader`类,它封装了一个RIFF WAVE文件并解析它,这样您的C#代码就可以轻松地获取其格式属性和WAVE数据本身。
  • 一个`RiffWaveWriter`类,它接受采样PCM WAVE数据并组装一个符合RIFF标准的WAVE文件写入磁盘。
  • `MdxSoundBuffer`类,它封装并管理MDX DirectSound“辅助缓冲区”,这是MDX用于播放声音的内存对象。既有静态子类,也有流式子类。`StaticSoundBuffer`用于播放存储在内存中的短声音。`StreamingSoundBuffer`和`SimpleStreamingSoundBuffer`用于任意长的声音文件,它们存储在磁盘上,只需流经一个短的循环内存缓冲区即可播放。每个都包含自己的服务器线程,以保持MDX辅助缓冲区充满。我将在下面指出这两个类之间的区别。
  • 一个小型对话框,`SimpleMdxPlayer`,它与`SimpleStreamingSoundBuffer`配合使用,展示了在MDX DirectSound中播放静态和流式声音最简单可靠的方法。
  • 另一个对话框,`MdxSoundSurveyor`,让您可以在以各种方式设置`DirectSound`设备和缓冲区时,尝试和探索系统上发生的一些有趣事情。它还演示了从某些类型的错误中恢复,包括“内存不足”错误、格式错误的*.wav*文件以及上述错误引起的虚假事件。

背景

WAVE声音文件

媒体文件(包括在Windows世界中的AVI和WAV)普遍遇到的存储标准是“资源交换文件格式”(RIFF)。在Code Project和更广泛的网络上,有几篇或多或少清晰的文章介绍了RIFF。我发现最有用的两篇是这篇维基百科文章和这篇MSDN文章

尽管RIFF包含数十种不同类型的资源文件,但MDX DirectSound仅能理解WAVE(.WAV.wav)。此外,MDX DirectSound仅适用于线性PCM(脉冲编码调制)文件,并且根据DirectX 9.0 SDK文档中关于WaveFormat.BitsPerSample属性的说明,仅支持8位或16位样本。这实际上是Microsoft的MDX架构师施加的任意限制,并非RIFF规范或PCM采样技术固有的。如果您或我想要做更花哨的事情,就得靠自己了!

一些术语:在现实世界中,“PCM”意味着(声音)信号以规则的时间间隔进行采样,并且每个样本都完全表示。它没有指定这些瞬时值如何近似(“量化”)和存储。在这个项目中,我使用16位整数和标准立体声排列。这意味着每个时间样本都有两个16位数字:4字节。单个时间样本中两个通道值的集合称为“帧”。

双通道、16位线性PCM,以44100 Hz采样,是“标准”CD音质。它非常常见,足以满足本示例的需求。然而,在实际生活中,浮点样本更容易进行数学运算。这就是为什么我有自己的`WaveFormatTag`版本。这样,我可以在最终转换为16位线性PCM以馈送给声卡之前,尽情地进行处理,而不会累积显著的算术误差。

我最初使用Visual Studio 2003和.NET 1.1开发了这个项目,它仍然依赖于2005年12月的MDX DirectSound文档。请参考该文档以获取我未涵盖的许多内容的背景和澄清。我们可能会在后续更新中找到更好的文档(和更完整的MDX类)——我希望如此!

MDX的价值主张是更快的开发速度,同时拥有非托管版本的大部分性能。缺点是:

  • 适度的性能损失,大约在5%或10%左右。
  • 由于托管环境中.NET垃圾回收器的操作,比非托管C++更不可预测的计时(即使考虑到Windows绝不是实时操作系统)。
  • 显著粗略且有时非常令人困惑的文档。
  • 比非托管API的类库更不完整。

哦,而且好的示例代码也不多。事实上,我发现即使要回答关于MDX编程的中级问题,也需要查阅非托管C++编程的文档。尽管如此,我还是很乐意用C#使用MDX。真的。

DirectSound 设备类

为了播放声音,`DirectSound`类需要一个`DirectSound.Device`对象,如MSDN文档中所述。这是硬件声音I/O设备的属性、功能和低级驱动程序的基本编程接口。有许多不同的声音I/O设备,具有相当令人困惑的功能阵列(请参阅`Microsoft.DirectX.DirectSound.Caps`结构文档以获取可能需要了解的更多信息)。在我的`MdxSoundSurveyor_Load(*)`方法中,我找到了默认的`Device`,实例化了一个`Device`对象,并将其`Caps`结构转储到`DebugConsole`(见下文)。我只使用最基本的默认功能,除了将协作级别设置为“`Priority`”,这是唯一合适的选择。检查默认设备的属性可以帮助您了解声音I/O设备(或“声卡”)可以做什么,以及稍后设置`SecondaryBuffers`时会发生什么变化。

辅助缓冲区

作为.NET程序员,您可能不会直接处理向`Device`及其关联的主缓冲区馈送数据。相反,您为每个声音资源或文件创建`DirectSound.SecondaryBuffer`对象。短声音可以非常简单地完整加载到“`static`”`SecondaryBuffer`中。但音频占用大量内存,“短”歌曲很容易占用30MB。尝试施特劳斯华尔兹,您就会遇到“内存不足”异常。这就是为什么有困难的方法:将数据流式传输到一个短的`SecondaryBuffer`(大约1-2秒的数据),并在声音播放时不断用新样本填充它。

`SecondaryBuffers`的规范和操作可能相当令人困惑。我将尝试用比SDK更少的文字解释您需要了解的内容。但我会省略许多您不需要了解才能理解本文的内容。

当您创建`SecondaryBuffer`时,您通过创建和修改一个`Microsoft.DirectX.DirectSound.BufferDescription`对象并将其传递给构造函数来指定声音文件的特性以及缓冲区的属性和“位置”。当您使用现代的基于PCI总线的系统时,“位置”属性并不意味着它可能看起来的意思。因为PCI总线足够快,可以将多个声道的声音传输到声卡,所以您的`SecondaryBuffers`中的声音数据实际上位于主系统内存中。(许多(如果不是大多数)现代声卡本身没有声音数据缓冲区内存。)如果您指定`LocateInHardware`属性,实际上发生的一切是MDX框架尝试设置声卡以从内存中读取您的声音数据并进行算术运算,将您的声音混合到您通过扬声器听到的声音流中。

实时混音在不久前可能会显著增加CPU负担。但如今,CPU速度如此之快,以至于让它们进行算术运算将十几额外通道混音到主声音输出中根本不是问题。而且,事实证明,仅仅指定`LocateInSoftware`可以最小化流式缓冲区的通知事件的问题。(这些将在下一节中介绍:请继续阅读!)DirectSound的非托管C++文档提到了这个问题,但MDX(C#)文档没有。(SuperWill在本文的第一次修订中发布了一条消息,指出了一个部分解决方案。我怀疑他熟悉C++ DirectX API!)

不幸的是,如果您让框架决定,并且您的声卡支持硬件缓冲区,它很可能会选择`LocateInHardware`——至少在我的开发系统上是这样!那么您将不得不处理大量虚假通知。

事实证明,这仍然不是全部。即使我使用软件辅助缓冲区,在我的每个系统上,在缓冲区的第一个段内,我仍然会收到一个单独的虚假通知。解决这个问题的最简单方法是每次只加载4个缓冲区扇区中的3个。

我的`SimpleMdxPlayer`使用软件辅助缓冲区进行流式传输,并且只填充了4个扇区中的3个,从而避免了虚假通知事件问题。

播放期间的通知

`SecondaryBuffer`对象可以在播放时发送通知事件,当播放指针通过缓冲区中的预设位置时。这仅涉及与Win32 `WaitHandle`s的轻微接触,这是一个许多.NET程序员乐于避免的话题。但这确实是了解何时重新填充缓冲区中声音数据最有效的方法。我注意到Ianier的鼓机代码以固定的时间间隔轮询缓冲区中的位置,而不是使用Notify事件。对于他的应用程序,有多个流,这更简单。但是由于他的定时器驱动代码可能无法与声音的实时播放精确同步,他必须轮询播放和写入指针的当前状态,并拉取可变数量的声音数据到缓冲区中,以确保它不会耗尽。另一方面,DirectX SDK `CaptureSound`示例使用了通知事件,这与MSDN上MDX `DirectSound`文档中建议的方式有些类似。

具体来说,MSDN关于“使用流缓冲区”的部分指示我们如下:“当所有数据都已写入缓冲区时,在最后一个有效字节处设置通知位置,或轮询此位置。当光标到达数据末尾时,调用`Buffer.Stop`。”实际上,`CaptureSound`示例在其通知位置数组中有一个额外的通知事件空间,所以我认为Microsoft程序员打算这样做。问题是,在缓冲区播放时尝试设置通知会导致`INVALID_CALL`异常。哎呀。

别怕:这也有一个解决办法。在执行代码将数据输入`SecondaryBuffer`与将其转换为声卡数模转换器(“DAC”)中的声音的真实世界时钟之间,总会存在一些管道延迟。因此,可以利用这种延迟,调用`SecondaryBuffer.Stop()`,在数据末尾设置通知事件,然后再次调用`SecondaryBuffer.Play()`,(可能)不会中断声音流。(感谢Aaron Lerch的这一观察。)我着重在构造函数中预设尽可能多的这种小动作,以最大限度地减少`Stop()`ed所花费的时间。

正如所有数学家和程序员所知,边界条件很棘手。我们的项目也是如此。在`CaptureSound`示例中,捕获停止后,缓冲区中剩余的样本会“手动”处理,而不是通过事件。这很容易,因为我们知道结尾在哪里,可以忽略`CaptureBuffer`中剩余的(陈旧)数据。而且它不需要实时发生!

Windows 是很多东西,但它不是一个硬实时操作系统。因此,在我们的播放器中,即使我们可以设置数据结束事件通知,我们也无法真正保证可以在旧数据在缓冲区中播放之前调用`SecondaryBuffer.Stop()`。所以,为了专业起见,我确保在声音结束后有一段静音,这段静音几乎永远不会被播放。这很少会很重要,但我们大多数人从经验中知道,会出问题的,最终都会出问题。

错误

是的,即使是完美的代码(是啊,没错)也可能出现错误。有些可以优雅地处理,有些则不能。我上面提到过,如果您尝试将过大的声音文件加载到静态`SecondaryBuffer`中,或者它刚好合适,然后您自然地尝试播放它,您可能会得到`OutOfMemoryException`。(为什么播放缓冲区比加载它需要更多的内存尚不清楚,但我见过这种情况导致异常。也许是虚拟与真实的区别!)无论哪种情况,您都希望捕获异常,释放与声音文件、流和缓冲区相关的内存及其他(设备)资源,并提示用户尝试其他操作。我见过的关于.NET中确定性终结的最佳、最简洁和最有效的处理方法是在Juval Lowy的著作《**Programming .NET Components**》中。他的确定性终结模板,我用于处理`Dispose()`、`Close()`等方法,可从iDesign网站获取。

我在此推荐并确认在我的代码中使用了他的技术。

免费!每个Windows应用程序都附带控制台

还有一些背景知识您可能已经知道,但我发现在开发中它非常有用,所以我想在这里指出。如果您将Windows Forms应用程序构建为控制台应用程序(通过右键单击解决方案资源管理器中的项目设置输出类型属性),您将看到一个背景控制台,如我的屏幕截图所示。我在调试模式下使用我的`DebugConsole`类写入控制台,该类具有`[Conditional("DEBUG")]`属性,因此如果您进行发布构建,它就会消失。我的`MdxSoundSurveyor`类大量使用了`DebugConsole`,让您可以看到设备、缓冲区以及通过`StreamingSoundBuffer`流式传输数据的线程操作的许多细节。下载中包含的*MdxSoundSurveyor.exe*是在调试模式下构建为控制台应用程序的,因此当您在自己的系统上运行它时,您将看到所有内容。

代码导览

好了,背景知识够多了(也许有点太多了?)。我是这样处理这些问题的。

命名空间 StreamComputers.Riff

首先,在`StreamComputers.Riff`命名空间中,我有一些类用于处理RIFF WAVE音频文件。`abstract`类`RiffWaveManager`持有对文件的`Stream`对象的引用,以及音频WAVE文件的格式和其他属性。它提供了一些辅助方法,这些方法返回多行字符串,这些字符串在您希望使用`DebugConsole`或任何其他工具来调查文件和格式属性时很有用。

`RiffWaveReader`子类的构造函数接收一个WAVE文件名字符串,为其创建一个`FileStream`,并在`FileStream`上创建一个`BinaryReader`,然后尝试解析文件以提取其格式和长度属性。如果它发现一个损坏的文件(尝试打开badRIFF.wav),它会捕获错误,自行释放,并将自定义的`RiffParserException`抛给构造它的对象,在我们的例子中是Windows Forms应用程序。这样,应用程序就可以通知用户他的音乐商欺骗了他,并礼貌地询问他是否要尝试另一个文件。

/// <param name="fileName">name of RIFF WAVE file to work with.</param>
public RiffWaveReader(string fileName)
{
    try
    {
        m_Stream = new FileStream(fileName, FileMode.Open, FileAccess.Read);
        m_Reader = new BinaryReader(m_Stream);
        ParseWaveInfo();
    }
    catch (RiffParserException)
    {
        Cleanup();
        throw;
    }
}

如果文件符合其要求(即使音乐不符合),构造的`RiffWaveReader`对象会公开格式和长度属性,并提供将数据从文件读取到传输缓冲区的方法。`GetDataBytes(*)`对于传输到MDX缓冲区很有用,而`GetPCM16DataFrames(*)`则访问音频以进行进一步处理,并可能写入输出文件。这两个方法都接受一个起始位置,因此您可以“寻找到”声音文件中的某个点开始读取。

`RiffWaveWriter`类封装了一个文件流和`BinaryWriter`,用于创建新的WAV文件。正如我警告过您的,我只处理标准“CD音频”格式——但您应该能够轻松地添加方法来处理其他PCM格式。

(不,MP3不是PCM格式。**请不要**问我关于制作或播放MP3的问题——我还有耳朵。如果您想了解更多关于音频质量的信息,请参阅我的文章“这不只是火箭科学”,刊登在Positive Feedback在线杂志上。)

`SetCD16Info()`方法就是这样做的,为该格式设置RIFF头。`WriteHeader()`实际上将头写入文件流,而`WritePCM16DataFrames(*)`也做了它所说的——这是您将处理过的音频放入文件的方式。完成之后,调用`WriteFinal()`,它会修复两个长度字段,然后调用`Close()`将文件刷新到磁盘并关闭它,然后处理写入器、流和`RiffWaveWriter`对象本身。

命名空间 StreamComputers.MdxDirectSound

`StreamComputers.MdxDirectSound`命名空间包含Windows Forms应用程序`MdxSoundSurveyor`。它旨在以调试模式构建为Windows控制台应用程序,这就是我为您制作MdxSoundSurveyor.exe所做的。它让您可以探索MDX `DirectSound`在您的系统上工作的一些细节。您可以通过执行以下操作来使用它:

  • 选择一个 WAV 文件
  • 如果您愿意,可以将其截断到某个短长度并写入(对测试非常有用!)。
  • 创建一个静态或流式缓冲区,该缓冲区将“位于”(实际上是混音)在硬件或软件中,最后
  • 使用经典的三个按钮音频播放器界面播放缓冲区:播放、暂停和停止。

有了背景中的免费控制台,如果您选择该选项,您可以看到关于声音设备、WAV文件、缓冲区和流数据传输进度的精彩事实。

与任何GUI一样,困难的部分是让用户按逻辑顺序进行操作,并防止不合理的输入。与任何GUI一样,这个GUI也不完美——但它可以使用。

首次加载时,Surveyor会找到默认声音设备并将其Caps结构转储到控制台。在创建辅助缓冲区之前,请注意可用缓冲区的数量和空闲硬件内存字节数。我的Creative SB Audigy2有62个空闲“缓冲区”(单声道混音通道)和0个空闲字节,当我没有运行其他启用声音的应用程序时。这正是我的预期:Windows占用了64个最大“缓冲区”(通道)中的2个用于立体声系统声音,并且板上没有内存。

现在,选择一个声音文件,看看`RiffWaveReader`是否通过。如果没有,请再试一次。

接下来,选择您希望通过声卡硬件(“在硬件中定位(混音)”)还是CPU(“在软件中定位(混音)”)将声音混入输出。

现在您可以选择静态或流式缓冲区来播放它。当您点击“创建缓冲区”时,标签控件会显示缓冲区的大小(如果它不适合,则会弹出一个消息框告诉您尝试其他大小)。对于流式情况,我选择了一个足够小的缓冲区大小,几乎任何系统都应该有足够的空闲内存来使用它。如果您没有256K,那么上帝保佑您。

如果您选择“在硬件中定位(混音)”,则硬件设备的Caps现在应该反映出可用“缓冲区”(通道)数量减少。

现在我们来看看代码。抽象类`MdxSoundBuffer`持有对MDX `DirectSound SecondaryBuffer`的引用。创建何种`SecondaryBuffer`取决于子类。它还承诺其子类将实现`IPlayable`接口:您猜对了,`Play()`、`Pause()`和`Stop()`。

`StaticSoundBuffer`子类很简单。它尝试创建一个`SecondaryBuffer`来保存文件,并捕获可能合理发生的异常:如果您的品味超出了您的预算,则会抛出`OutOfMemoryException`;如果文件损坏,则会抛出`ArgumentException`。它会通知用户问题所在,并向上抛出,以便其客户端(GUI)可以处理它,并让用户再次尝试。

回到GUI,如果您选择了静态缓冲区单选按钮,则缓冲区长度是WAVE文件整个数据负载的大小。当您点击“播放”时,您应该能从系统默认的声音设备听到声音。如果您听不到,请检查您的系统声音设置——这不是我的错。您可以暂停、取消暂停,让它播放到结束,或者点击“停止”。一切都很正常。

现在乐趣开始了

如果您选择一个流式缓冲区,然后点击“创建缓冲区”,您会看到一个更小的缓冲区大小。正如我在`StreamingSoundBuffer`子类的注释中解释的,我为缓冲区选择了一个不错的2的幂次大小,256K字节,以CD音频速率计算大约是1.5秒的声音。(最终,我们会告诉缓冲区在到达末尾时播放**并循环**。)现在乐趣开始了。

构造函数使用`RiffWaveReader`解析文件并确保其合法。然后它用文件的格式属性创建一个`DirectSound.WaveFormat`结构,并将其传递给`DirectSound.BufferDescription`对象的构造函数。然后我将它的一些属性设置得与默认值不同。请查阅MDX文档以了解大量的可能性,其中很少一部分与我相关。最后,一个不同的`SecondaryBuffer`构造函数(有七个!)设置了一个流式类型的缓冲区:一个**不**加载声音数据本身的缓冲区。

public StreamingSoundBuffer(string fileName, Device dev, bool inHardware)
{
    m_inHardware = inHardware;

    // UI button should be disabled if no file
    try
    {
        // Use a RiffWaveReader to access the WAVE file
        m_RiffReader = new RiffWaveReader(fileName);
    }
    catch (RiffParserException)
    {
        if (m_RiffReader != null)
        {
            m_RiffReader.Dispose();
        }
        throw;
    }
    WaveFormat format = m_RiffReader.GetMDXWaveFormat();
    DebugConsole.WriteLine(MdxInfo.WaveFormatAsString(format));
    DebugConsole.WriteLine("WaveDataLength: {0,12} bytes", 
                            m_RiffReader.DataLength);

    // describe a SecondaryBuffer suitable for streaming, 
    //and very selfish focus
    BufferDescription bdesc = new BufferDescription(format);
    bdesc.BufferBytes = m_StreamBufferSize;
    bdesc.ControlPositionNotify = true;
    bdesc.CanGetCurrentPosition = true;
    bdesc.ControlVolume = true;
    bdesc.LocateInHardware = m_inHardware;
    bdesc.LocateInSoftware = !m_inHardware;
    bdesc.GlobalFocus = true;
    bdesc.StickyFocus = true;

    try
    {
        m_SecondaryBuffer = new SecondaryBuffer(bdesc, dev);
        m_SecondaryBuffer.SetCurrentPosition(0);
        m_secondaryBufferWritePosition = 0;
        // ie not attenuated
        m_SecondaryBuffer.Volume = 0;
        DebugConsole.WriteLine(MdxInfo.BufferCapsAsString(
                            m_SecondaryBuffer.Caps));

        // Create a notification Event object, to fire 
        //at each notify position
        m_NotificationEvent = new AutoResetEvent(false);

        // Preset as much of the EndNotificationPosition array as possible 
        //to avoid doing it in real-time.
        m_EndNotificationPosition[0].EventNotifyHandle = 
                                     m_NotificationEvent.Handle;
        m_predictedEndIndex = (int) (m_RiffReader.DataLength 
                                     % m_StreamBufferSize); //[bytes]

        // ready to go:
        m_MoreWaveDataAvailable = (m_RiffReader.DataLength 
                                       > m_dataBytesSoFar);
        m_State = BufferPlayState.Idle;
    }
    catch (ApplicationException e)
    {
        // This may be due to lack of hardware accelerator: 
        //let GUI deal with it.
        DebugConsole.WriteLine(
                      "Failed to create specified StreamingSoundBuffer.");
        StringBuilder msg = new StringBuilder(
                      "Cannot create specified StreamingSoundBuffer:\n");
        msg.Append("ApplicationException encountered:\n");
        msg.Append(e.ToString());
        MessageBox.Show( msg.ToString(),
            "Buffer Specification Error.",
            MessageBoxButtons.OK,
            MessageBoxIcon.Exclamation);
        throw (e);                                  
    }
}

为了用数据加载缓冲区,我们需要更多的机制。正如我在背景讨论中提到的,`DirectSound`提供了一种方法,让缓冲区在播放了一些数据后向我们发送事件,我们可以安全地将新数据填充到旧数据之上。我使用单个`AutoResetEvent`作为信号,并在缓冲区中设置了四个点,我希望在这些点收到通知。因此,我将缓冲区定义为四个相等的部分,每个部分包含大约370毫秒的声音数据。(这与MDX文档中描述的安排不同,后者似乎使用多个事件。)与缓冲区关联的`Notify`对象在其`SetNotificationPositions(*)`方法中接收`BufferPositionNotify`结构数组作为参数。因此,在我们用前256K的声音填充缓冲区并将其设置为播放后,它将每370毫秒触发一次事件。

private BufferPositionNotify[] m_NotificationPositionArray
    = new BufferPositionNotify[m_numberOfSectorsInBuffer];

private AutoResetEvent m_NotificationEvent;

private BufferPositionNotify[] m_EndNotificationPosition 
                                             = new BufferPositionNotify[1];

//...

private void SetFillNotifications(int numberOfSectors)
{
    // Set up the fill-notification positions at last byte of each sector.
    // All use the same event, in contrast to recipe in DX9.0 SDK Aug 2005
    // titled "DirectSound Buffers | Using Streaming Buffers" 
    for (int i = 0; i < numberOfSectors; i++)
    {
        m_NotificationPositionArray[i].Offset = (i + 1) * m_SectorSize - 1;
        DebugConsole.WriteLine("Fill Notification set at {0,12}", 
                                     m_NotificationPositionArray[i].Offset);
        m_NotificationPositionArray[i].EventNotifyHandle = 
                                                m_NotificationEvent.Handle;
    }

    m_Notifier = new Notify(m_SecondaryBuffer);

    // set the buffer to fire events at the notification positions
    m_Notifier.SetNotificationPositions(m_NotificationPositionArray, 
                                                          numberOfSectors);
}

但GUI不会乐于每隔370毫秒就被打扰。所以`StreamingSoundBuffer.CreateDataTransferThread()`创建了一个专用线程来处理事件并将数据以64K字节块传输到缓冲区。

private void CreateDataTransferThread()
{
   // No thread should exist yet.
   Debug.Assert(m_DataTransferThread == null,
                 "CreateDataTransferThread() saw thread non-null.");

   m_AbortDataTransfer = false;
   m_MoreWaveDataAvailable = (m_RiffReader.DataLength > m_dataBytesSoFar);
   m_numSpuriousNotifies = 0;
   m_numberOfDataSectorsTransferred = 0;

   // Create a thread to monitor the notify events.
   m_DataTransferThread = new Thread(new ThreadStart(DataTransferActivity));
   m_DataTransferThread.Name = "DataTransferThread";
   m_DataTransferThread.Priority = ThreadPriority.Highest;
   m_DataTransferThread.Start();

   // thread will wait for notification events
}

线程将等待一个事件,然后尽职地执行其工作函数,即巧妙命名的`DataTransferActivity()`。

此时,我想将您的注意力转移到`SimpleStreamingSoundBuffer`类。它是我们一直在看的`StreamingSoundBuffer`代码的简化版本,所以更容易理解。

(保留`StreamingSoundBuffer`的唯一原因是为了处理(由于某些我未曾想到的原因)您需要一个由声卡硬件处理和混音的流式缓冲区,或者如果您真的想看看播放期间发生的奇怪情况时所导致的问题。)

`SimpleStreamingSoundBuffer`线程的主要任务是`TransferBlockToSecondaryBuffer()`。它还必须注意两种特殊情况。

首先,它检查是否有更多的波形数据可供传输。如果有,它会检查是否已被中止(用户点击了停止按钮或其`SoundBuffer`正在被另一个替换)。在这种情况下,它会立即返回并且线程终止。否则,它会等待一个通知事件。当事件发生时,它就有空间将另一个块传输到缓冲区。

private void DataTransferActivity()
{
    int endWaveSector = 0;
    while (m_MoreWaveDataAvailable)
    {
        if (m_AbortDataTransfer)
        {
            return;
        }
        //wait here for a notification event
        m_NotificationEvent.WaitOne(Timeout.Infinite, true);
        endWaveSector = m_secondaryBufferWritePosition / m_SectorSize;
        m_MoreWaveDataAvailable = TransferBlockToSecondaryBuffer();
    }

    // Fill one more sector with silence, to avoid playing old data during
    // the time between end-event-notification and SecondaryBuffer.Stop().
    Array.Clear(m_transferBuffer, 0, m_transferBuffer.Length);
    m_NotificationEvent.WaitOne(Timeout.Infinite, true);
    int silentSector;
    silentSector = m_secondaryBufferWritePosition / m_SectorSize;
    WriteBlockToSecondaryBuffer();

    // No more blocks to write: Remove fill-notify points, 
    //and mark end of data.
    int dataEndInBuffer = m_dataBytesSoFar % m_StreamBufferSize;
    SetEndNotification(dataEndInBuffer);
    Debug.Assert(dataEndInBuffer == m_predictedEndIndex,
        "Wave Data Stream end is not at predicted position.");

    // end of data or the silent sector
    bool notificationWithinEndSectors = false;
    // Wait for play to reach the end
    while (!notificationWithinEndSectors)
    {
        m_NotificationEvent.WaitOne(Timeout.Infinite, true);

        int currentPlayPos, unused;
        m_SecondaryBuffer.GetCurrentPosition(out currentPlayPos,out unused);
        int currentPlaySector = currentPlayPos / m_SectorSize;

        notificationWithinEndSectors = currentPlaySector == endWaveSector
                                        | currentPlaySector == silentSector;
    }
    m_SecondaryBuffer.Stop();
    m_State = BufferPlayState.Idle;
}

`TransferBlockToSecondaryBuffer()`方法调用`RiffWaveReader.GetDataBytes(*)`。当声音文件的数据流结束时,它通过返回实际从文件中读取的字节数来表示这一事实,然后线程注意到它小于块大小。(`RiffWaveReader`用0填充传输缓冲区的其余部分。)

我们现在差不多完成了。我们可以直接在声音数据末尾设置结束通知事件,等待它,调用`Buffer.Stop()`,然后就可以收工了。但我们不知道获取事件并响应需要多长时间,而且数据结束可能正好在扇区的末尾。在这种情况下,缓冲区可能会播放旧数据片刻,然后才真正停止。为了专业起见,我们再设置一个完整的扇区为静音(在CD音频情况下为0),以避免发出刺耳的噪音。(这可能看起来没有必要,但单个坏的数字音频数据样本比坏像素,甚至一帧视频,更容易被察觉。)

线程等待播放到达末尾,`Stop()`缓冲区,并将`SimpleStreamingSoundBuffer`置于空闲状态。然后,它从其工作方法返回,并前往死线程的归宿。(是的,我知道……)

这基本上涵盖了`SimpleStreamingSoundBuffer`的操作。

它的老大哥`StreamingSoundBuffer`要复杂得多,原因是我之前多次提到的bug。

当我开始实验流式缓冲区时,我花了一两天的时间才弄清楚发生了什么。似乎在支持硬件的声卡驱动程序与DirectSound交互的方式中存在一个错误。当流式缓冲区“位于硬件中”时,也就是说,声卡负责从系统内存访问声音数据并将其混合到输出流中,通知事件偶尔会无故触发。这种情况发生得不可预测,但似乎与我电脑上的其他活动相关——打开IE窗口,移动文件等。有时它似乎只是为了好玩而触发。我可以让它通过同时在Windows Media Player中播放一些MP3声音来产生额外的事件,使它变得疯狂。如果您查看`MdxSoundSurveyor`的屏幕截图,您会看到传输线程忠实地报告了许多这些虚假事件。我个人可以听到它们是歌词的向前跳跃——只是一小部分秒。所以我设置了一个陷阱,如代码所示。好消息是常规事件是可靠的。如果它们丢失了,修复会更困难!一个额外的事件会让线程将数据写入当前正在播放的片段,这是一个错误。所以过滤器会查看当前的播放指针,并拒绝将一个块传输到它所在的片段。

(我认为这与声卡只有一个中断来获取Windows操作系统的注意有关。也许没有可靠的方法可以判断哪个缓冲区(即通道或声音数据流)需要服务。但我认为应该实现某种向量或原因识别。如果您了解声卡设备驱动程序和硬件的详细信息,请告诉我到底发生了什么!)

幸运的是,当CPU进行数据访问和混音时,这种混淆不会发生。因此,可以通过始终在用于构造流式`SecondaryBuffer`的`BufferDescription`对象中选择“`LocateInSoftware`”来避免此问题。这已连接到`SimpleStreamingSoundBuffer`中,`SimpleMdxPlayer`使用它。如果您真的不需要让CPU承受缓冲处理和混音的微小额外负担,那么务必使用简单版本!

(还有一点:因为用于短声音的静态缓冲区不使用通知事件,所以它们没有这个问题。因此,例如,如果您正在编写一个有许多短音效或循环的游戏,您可以让DirectX框架为您决定使用硬件将这些缓冲区混音到输出流中,从而节省一些CPU周期,同时仍然避免虚假事件问题。这就是我在`SimpleMdxPlayer`中所做的。)

嗯,旅程结束了。希望你喜欢。另一方面,如果你认为这太多文字了,那就直接看代码吧。

关注点

  • 我建议使用`SimpleStreamingSoundBuffer`,除非您绝对必须使用声卡硬件来混音流式声音。只需将`SimpleMdxPlayer`作为示例即可。
  • 如果您使用`MdxSoundSurveyor`进行了一些探索,请告诉我,如果您在缓冲区“位于软件中”时,是否曾收到过不止一个虚假通知。同样,如果您的系统在使用硬件中的流式缓冲区时,同时播放Windows Media Player的声音,却没有收到这些通知,请告诉我您使用的声卡品牌和型号、驱动程序版本等信息!
  • 如果您发现`DirectSound`或我的代码有任何其他错误,请告诉我!

历史

  • 2006.02.24 - 首次发布于The Code Project世界。代码和文章 © 2006 Gary W. Schwede 和 Stream Computers, Inc.
  • 2007.02.14 - 修订版2。扩展了代码与硬件交互的解释。修复了关于RIFF fmt块位置的假设,该假设导致一些合法文件被拒绝。简化了流式声音的处理。包括一个详细的“勘测器”应用程序和一个简单的播放器,两者都提供源代码和.exe文件。重构了大部分代码,并使播放器的操作更像一个好的FSM。本文献给我们的忠实猫Mickey Mouse-Eater的记忆,1994.11.11 -- 2007.02.09。直到我们再见,小家伙。代码和文章 © 2007 Gary W. Schwede 和 Stream Computers, Inc.

许可证

本文档未附带明确的许可,但可能在文章内容或下载文件中包含使用条款。如有疑问,请通过下面的讨论区联系作者。作者可能使用的许可列表可在此处找到:这里

© . All rights reserved.