用 DirectSound 生成电子鼓!

2019-04-14 18:00发布

用 DirectSound 生成电子鼓

发布日期: 11/12/2004 | 更新日期: 11/12/2004 Ianier Munoz
Chronotron 摘要:特约专栏作家 Ianier Munoz 使用托管 Microsoft DirectX 库和 C# 来即时合成音频流,从而生成了电子鼓。 下载本文的源代码
*
本页内容
鼓声大作 鼓声大作简介 简介DirectSound 概述 DirectSound 概述流式音频播放器 流式音频播放器使用 DirectSound 实现 IAudioPlayer 使用 DirectSound 实现 IAudioPlayer 电子鼓引擎 电子鼓引擎将代码组合在一起 将代码组合在一起小结 小结编码问题 编码问题资源 资源

鼓声大作

(本介绍由 Duncan Mackenzie 提供。) Ianier 有一份很棒的工作;他为 DJ 们编写代码,以使他们能够使用诸如 Microsoft® Windows Media® Player 之类的使用者软件来进行专业数字信号处理 (DSP) 工作。这是一份美妙的工作,并且让我们感到幸运的是,他正在钻研托管代码和托管 Microsoft® DirectX® 领域。在本文中,Ianier 已经生成了一个演示软件(参见图 1),该软件将使您在几分钟之后通过您计算机上的小扬声器发出您自己的低音打击乐声。这是一个托管电子鼓,正是它使您可以配置和播放多个声道的取样音乐。代码无须进行任何实际配置就应该能够工作,但您在打开并运行 winrythm 示例项目之前,您必须确保下载并安装(然后重新启动)DirectX SDK(可从这里获得)。 code4fun02032004_fig01
1. 电子鼓的主要窗体
(欧耶,欧耶...咚咚,咚咚,咚咚... 返回页首返回页首

简介

在 DirectX 9 SDK 发布以前,Microsoft® .NET Framework 令人失望地没有任何声音。解决这一局限性的唯一方法是通过 COM Interop 或 P-Invoke 访问 Microsoft® Windows® API。 托管 Microsoft® DirectSound®(它是 DirectX 9 的一个组件)使您可以在 .NET 中播放声音,而无须求助于 COM Interop 或 P-Invoke。在本文中,我将说明如何通过即时合成音频样本来实现简单的电子鼓(参见图 1),并且使用 DirectSound 播放得到的音频流。 本文假设您熟悉 C# 和 .NET Framework。一些基本的音频处理知识也可以帮助您更好地理解这里描述的概念。 本文附带的代码是用 Microsoft® Visual Studio® .NET 2003 编译的。它要求具有 DirectX 9 SDK(您可以从这里下载它)。 返回页首返回页首

DirectSound 概述

DirectSound 是 DirectX 的一个组件,它使应用程序可以用与硬件无关的方式访问音频资源。在 DirectSound 中,音频播放单元是声音缓冲区。声音缓冲区属于音频设备,后者代表主机系统中的声卡。当应用程序希望使用 DirectSound 播放声音时,它会创建一个音频设备对象,在该设备上创建一个缓冲区,用声音数据填充该缓冲区,然后播放该缓冲区。有关不同的 DirectSound 对象之间的关系的详细说明,请参阅 DirectX SDK 文档资料。 根据声音缓冲区的预期用途,可以将它们分为静态缓冲区或流式缓冲区。静态缓冲区被用一些预定义的音频数据初始化一次,然后根据需要播放任意多次。这种缓冲区通常用于射击游戏以及其他需要短暂效果的游戏中。另一方面,流式缓冲区通常用于播放由于太大而无法放入内存的内容,或者那些无法事先确定其长度或内容的声音,如电话应用程序中说话者的声音。流式缓冲区是使用随着缓冲区的播放而不断用新数据刷新的小型缓冲区实现的。尽管托管 DirectSound 提供了有关静态缓冲区的优秀文档资料和示例,但它目前缺少有关流式缓冲区的示例。 然而,应该提到的是,托管 DirectX 确实包含一个用于播放音频流的类,即 AudioVideoPlayback 命名空间中的 Audio 类。该类使您可以播放大多数种类的音频文件,包括 WAV 和 MP3。但是,Audio 类不允许您以编程方式选择输出设备,并且它不为您提供访问音频样本的权限,以防您希望对其进行修改。 返回页首返回页首

流式音频播放器

我将流式音频播放器定义为一个从某个源中提取音频数据并通过某个设备播放这些数据的组件。典型的流式音频播放器组件通过声卡播放传入的音频流,但它还可以通过网络发送音频流或者将其保存到文件中。 IAudioPlayer 接口包含我们的应用程序应该了解的有关该播放器的所有信息。该接口还将使您可以将您的声音合成引擎从实际的播放器实现中分离出来,这在您希望将该示例移植到其他使用不同播放技术的 .NET 平台时可能很有用。/// /// Delegate used to fill in a buffer /// public delegate void PullAudioCallback(IntPtr data, int count); /// /// Audio player interface /// public interface IAudioPlayer : IDisposable { int SamplingRate { get; } int BitsPerSample { get; } int Channels { get; } int GetBufferedSize(); void Play(PullAudioCallback onAudioData); void Stop(); } SamplingRateBitsPerSampleChannels 属性描述了该播放器理解的音频格式。Play 方法开始播放由 PullAudioCallback 委托提供的音频流,而 Stop 方法不出意外地停止音频播放。 请注意,PullAudioCallback 要求将 count 字节的音频数据复制到数据缓冲区(它是一个 IntPtr)。您可能会认为我应该使用字节数组而不是 IntPtr,因为在 IntPtr 中处理数据会强迫应用程序调用需要非托管代码执行权限的函数。然而,托管 DirectSound 无论如何都需要这样的权限,所以使用 IntPtr 没有什么严重后果,并且在处理不同的样本格式和其他播放技术时可以避免额外的数据复制。 GetBufferedSize 返回自从上次调用 PullAudioCallback 委托以来已经被排到该播放器的队列中的字节数。我们将使用该方法来计算相对于输入流的当前播放位置。 返回页首返回页首

使用 DirectSound 实现 IAudioPlayer

正如我在前面提到的那样,DirectSound 中的流式缓冲区只是一个随着缓冲区的播放而不断用新数据刷新的小型缓冲区。StreamingPlayer 类使用流式缓冲区来实现 IAudioPlayer 接口。 让我们观察一下 StreamingPlayer 构造函数:public StreamingPlayer(Control owner, Device device, WaveFormat format) { m_Device = device; if (m_Device == null) { m_Device = new Device(); m_Device.SetCooperativeLevel( owner, CooperativeLevel.Normal); m_OwnsDevice = true; } BufferDescription desc = new BufferDescription(format); desc.BufferBytes = format.AverageBytesPerSecond; desc.ControlVolume = true; desc.GlobalFocus = true; m_Buffer = new SecondaryBuffer(desc, m_Device); m_BufferBytes = m_Buffer.Caps.BufferBytes; m_Timer = new System.Timers.Timer( BytesToMs(m_BufferBytes) / 6); m_Timer.Enabled = false; m_Timer.Elapsed += new System.Timers.ElapsedEventHandler(Timer_Elapsed); } StreamingPlayer 构造函数首先确保我们具有一个有效的 DirectSound 音频设备以便使用,并且如果未指定这样的设备,则它会创建一个新设备。为了创建 Device 对象,我们必须指定一个 Microsoft® Windows 窗体控件,以便 DirectSound 用来跟踪应用程序焦点;因此,使用了 owner 参数。然后,将创建并初始化一个 DirectSound SecondaryBuffer 实例,并且分配一个计时器。稍后,我将讨论该计时器的作用。 IAudioPlayer.StartIAudioPlayer.Stop 的实现几乎微不足道。Play 方法确保有一些音频数据要播放;然后,它启用计时器并开始播放缓冲区。与此对称的是,Stop 方法禁用计数器并停止该缓冲区。public void Play( Chronotron.AudioPlayer.PullAudioCallback pullAudio) { Stop(); m_PullStream = new PullStream(pullAudio); m_Buffer.SetCurrentPosition(0); m_NextWrite = 0; Feed(m_BufferBytes); m_Timer.Enabled = true; m_Buffer.Play(0, BufferPlayFlags.Looping); } public void Stop() { if (m_Timer != null) m_Timer.Enabled = false; if (m_Buffer != null) m_Buffer.Stop(); } 其思想是不断地向该缓冲区供给来自委托的声音数据。为了达到这一目标,计时器定期检查已经播放了多少音频数据,并且根据需要向该缓冲区中添加更多的数据。 private void Timer_Elapsed( object sender, System.Timers.ElapsedEventArgs e){ Feed(GetPlayedSize());} GetPlayedSize 函数使用缓冲区的 PlayPosition 属性来计算播放游标已经推进的字节数。请注意,因为缓冲区循环播放,所以 GetPlayedSize 必须检测播放游标何时返绕,并相应地调整结果。 private int GetPlayedSize(){ int pos = m_Buffer.PlayPosition; return pos < m_NextWrite ? pos + m_BufferBytes - m_NextWrite : pos - m_NextWrite;} 填充该缓冲区的例程名为 Feed,并且它显示在下面的代码中。该例程调用了 SecondaryBuffer.Write,该方法从流中提取音频数据,并将其写到缓冲区中。在我们所处的场合下,该流只是我们在 Play 方法中收到的 PullAudioCallback 委托的包装。 private void Feed(int bytes) { // limit latency to some milliseconds int tocopy = Math.Min(bytes, MsToBytes(MaxLatencyMs)); if (tocopy > 0) { // restore buffer if (m_Buffer.Status.BufferLost) m_Buffer.Restore(); // copy data to the buffer m_Buffer.Write(m_NextWrite, m_PullStream, tocopy, LockFlag.None); m_NextWrite += tocopy; if (m_NextWrite >= m_BufferBytes) m_NextWrite -= m_BufferBytes; } } 请注意,我们将添加到该缓冲区的数据量强行限制在某个界限以下,以便减少播放延迟。可以将延迟定义为传入的音频流中发生更改的时间与该更改被实际听到的时间之间的差值。如果没有这样的延迟控制,则平均延迟将大约等于缓冲区总长度,这对于实时合成器而言是不可接受的。 返回页首返回页首

电子鼓引擎

电子鼓是实时合成器的示例:一组表示每个可能的鼓音的样本波形(用音乐行话说,也称为“音 {MOD}”)被混合为遵循某些节奏模式的输出流,以模拟鼓手演奏。这就像听起来那样简单,因此让我们研究一下代码!

核心

电子鼓的主要元素是在 PatchTrackMixer 类中实现的(参见图 2)。所有这些类都在 Rhythm.cs 中实现。 code4fun02032004_fig02thumb
2. Rhythm.cs 的类关系图 Patch 类存放特定乐器的波形。Patch 是用包含 WAV 格式音频数据的 Stream 对象初始化的。这里,我将不会说明有关读取 WAV 文件的详细信息,但您可以观察一下 WaveStream 帮助器类以获得总体印象。 出于简单性的考虑,Patch 通过添加左声道和右声道(如果提供的文件是立体声)将音频数据转换为单声道,并且将结果存储在 32 位整数的数组中。实际的数据范围是 -32768 到 +32767,以便我们可以混合多个音频流,而无须注意溢出问题。 PatchReader 类使您可以从 Patch 中读取音频数据并将其混合到目标缓冲区中。将读取器从实际的 Patch 数据中分离出来是必要的,因为可以听到单个 Patch 在不同的位置多次播放。特别是当相同的声音在极短时间内多次出现时,会发生这种情况。 Track 类代表要使用单个乐器播放的一系列事件。曲目是使用一个 Patch、一些时间段(即可能的节拍位置)初始化的,同时还可以使用初始模式。该模式只是一个布尔值数组,其长度等于曲目中的时间段的个数。如果您将该数组的某个元素设置为 true,则应该在该节拍位置播放所选的 PatchTrack.GetBeat 方法为特定的节拍位置返回一个 PatchReader 实例,或者,如果不应该在当前节拍中播放任何内容,则返回 null。 Mixer 类在给定一组曲目的前提下生成实际的音频流,因此它实现了一个与 PullAudioCallback 签名相匹配的方法。混音器还跟踪当前节拍位置和当前正在播放的 PatchReader 实例的列表。 最困难的工作是在 DoMix 方法内部完成的,您可以在下面的代码中看到该方法。混音器计算有多少个样本与节拍持续时间相对应,并且随着输出流的合成推进当前节拍位置。要生成一组样本,混音器只须将正在当前节拍播放的音 {MOD}加在一起。private void DoMix(int samples) { // grow mix buffer as necessary if (m_MixBuffer == null || m_MixBuffer.Length < samples) m_MixBuffer = new int[samples]; // clear mix buffer Array.Clear(m_MixBuffer, 0, m_MixBuffer.Length); int pos = 0; while(pos < samples) { // load current patches if (m_TickLeft == 0) { DoTick(); lock(m_BPMLock) m_TickLeft = m_TickPeriod; } int tomix = Math.Min(samples - pos, m_TickLeft); // mix current streams for (int i = m_Readers.Count - 1; i >= 0; i--) { PatchReader r = (PatchReader)m_Readers[i]; if (!r.Mix(m_MixBuffer, pos, tomix)) m_Readers.RemoveAt(i); } m_TickLeft -= tomix; pos += tomix; } } 为了计算与给定速度的时间段相对应的音频样本的数量,混音器使用以下公式:(SamplingRate * 60 / BPM) / Resolution,其中,SamplingRate 是播放器的取样频率(以 Hz 为单位);Resolution 是每个节拍的时间段的数量;BPM 是速度(单位是节拍/分钟)。BPM 属性应用该公式以初始化 m_TickPeriod 成员变量。 返回页首返回页首

将代码组合在一起

既然我们具有了实现该电子鼓所需的所有元素,那么使工作顺利完成所需做的唯一一件事情是将它们连接在一起。下面是操作顺序: • 创建一个流式音频播放器。 • 创建一个混音器。 • 根据 WAV 文件或资源创建一组鼓音 {MOD}(声音)。 • 将一组曲目添加到该混音器,以播放所需的音 {MOD}。 • 为要播放的每个曲目定义模式。 • 使用该混音器作为数据源,启动播放器。 正如您可以在下面的代码中看到的那样,RythmMachineApp 类恰好完成了这一工作。public RythmMachineApp(Control control, IAudioPlayer player) { int measuresPerBeat = 2; Type resType = control.GetType(); Mixer = new Chronotron.Rythm.Mixer( player, measuresPerBeat); Mixer.Add(new Track("Bass drum", new Patch(resType, "media.bass.wav"), TrackLength)); Mixer.Add(new Track("Snare drum", new Patch(resType, "media.snare.wav"), TrackLength)); Mixer.Add(new Track("Closed hat", new Patch(resType, "media.closed.wav"), TrackLength)); Mixer.Add(new Track("Open hat", new Patch(resType, "media.open.wav"), TrackLength)); Mixer.Add(new Track("Toc", new Patch(resType, "media.rim.wav"), TrackLength)); // Init with any preset Mixer["Bass drum"].Init(new byte[] { 1, 0, 0, 1, 1, 0, 0, 0, 1, 0, 0, 1, 1, 0, 0, 0 } ); Mixer["Snare drum"].Init(new byte[] { 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0 } ); Mixer["Closed hat"].Init(new byte[] { 1, 1, 0, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 1, 0, 0 } ); Mixer["Open hat"].Init(new byte[] { 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1 } ); BuildUI(control); m_Timer = new Timer(); m_Timer.Interval = 250; m_Timer.Tick += new EventHandler(m_Timer_Tick); m_Timer.Enabled = true; } 这就是所有的操作。其余代码为电子鼓实现了一个简单的用户界面,以便使用户可以在计算机屏幕上创建有节奏的模式。 返回页首返回页首

小结

本文向您说明了如何使用托管 DirectSound API 创建流式缓冲区,以及如何即时生成音频流。我希望您在使用提供的示例代码时能够获得一些乐趣。您还可以考虑进行一些改进,如对加载和保存模式的支持、用于更改速度的用户界面控件、立体声播放等等。我将把这些工作留待您来完成,因为让我拥有全部的乐趣是不公平的…… 最后,我要感谢 Duncan 允许我将本文张贴到他的“Coding4Fun”专栏中。我希望您在使用这些代码时能够享受到像我在编写它们时一样多的乐趣。在以后的文章中,我将探讨如何将该电子鼓移植到 Compact Framework,以使其能够在 Pocket PC 上运行。 返回页首返回页首

编码问题

在这些“Coding4Fun”专栏文章的结尾,Duncan 通常会提出几个编码问题,如果您对它们感兴趣,可以进行一番研究。在阅读本文以后,我愿意邀请您仿效我的示例并创建一些使用 DirectX 的代码。请将您的作品张贴到 GotDotNet,并给 Duncan 发送电子邮件(duncanma@Microsoft.com),说明您所做的工作以及您感兴趣的原因。您可以随时向 Duncan 发送您的意见,但请只发送指向代码示例的链接而不是示例本身。 您对嗜好者内容有何见解?您希望看到其他人来做特约专栏作家吗?请通过 duncanma@Microsoft.com 告知 Duncan。 返回页首返回页首

资源

本文的核心是使用 DirectX 9 SDK(可以从这里获得)生成的,但您还应该参阅 MSDN Library 的 DirectX 部分(位于 http://msdn.Microsoft.com/nhp/default.asp?contentid=28000410)。如果您要寻找本主题的多媒体介绍,则 .NET 节目 (http://msdn.Microsoft.com/theshow/episode037/default.asp) 的一部分也重点讨论了托管 DirectX。 Coding4Fun code4fun02032004_fig03
Ianier Munoz 居住在法国梅斯市,并且是卢森堡的一家国际咨询公司的高级顾问和分析师。他已经创作了一些流行的多媒体软件,如 Chronotron、Adapt-X 和 American DJ 的 Pro-Mix。您可以通过 http://www.chronotron.com 与他联系。 转到原英文页面
返回页首返回页首