PracticeSharp (或 Practice#) - 用于练习乐器并进行播放的实用工具






4.94/5 (72投票s)
一个供音乐家使用的播放练习工具,可以减慢速度、改变音高、定义预设和循环音乐文件。
Github 仓库 - https://github.com/bigman73/practicesharp/
介绍
我弹了两年电吉他,但直到几个月前才开始跟随专业老师学习。复杂程度呈指数级增长——从仅仅尝试弹奏几个和弦,我现在弹奏的是很难掌握的连奏,特别是在学习一首新歌的时候。我的吉他老师使用一个应用程序来减慢播放速度——Amazing Slow Downer。
它是商业软件,我没打算花钱购买(当然它很实惠,但开源更好,而且我可以根据自己的喜好进行修改)。
我找到的一个开源替代品叫做 BestPractice。这个工具在 Windows XP 上运行良好。但它在 Windows 7 Pro 64 位上会崩溃,播放质量可以更好,UI 设计也可以更好(例如,音量条上的滑块按钮很小),最重要的是,它缺少一个关键功能——预设。我下载了源代码,但发现代码是用 Borland C++ Builder 编写的——这并不是我想要开发的开发环境。
用户界面和预设是值得讨论的一点——为了让我的吉他获得出色的音效,我之前购买了 Boss ME-70(如下图所示)。
这款硬件确实很棒,不仅因为出色的效果和音质,还因为它出色的 UI 设计。没有菜单——一切都一目了然,使用直观。对我来说,最好的部分是预设——你可以手动更改一些设置(例如:放大器仿真模式、压缩器和延迟),然后一旦你对声音满意,只需将其保存到持久的预设中即可。这对于练习非常有用——你第二天回来时,该预设仍然在那里。无需从头开始设置一切。
我非常希望在播放练习工具中拥有这些预设——能够定义一个具有一定慢速因子和可能还有音量的连奏,或许还可以设置一个循环以允许对该部分进行重复练习。一旦“播放部分”被正确定义,我就可以像我的 Boss ME-70 一样将其保存到预设中。
以前,当我使用 BestPractice 时,我不得不把所有的设置都写在纸上,然后每次开始练习时都要手动“拨入”它们。有了 Practice#,我希望能够让这些设置持久化,并能够将相同的设置保存为两个或更多个预设,每个预设都有不同的慢速(速度),这样连奏就可以以 70% 的速度开始播放,当您更有信心时提高到 85%,最后是 100% 的常规速度。因此,一旦我有了内部的“业务需求”,就该转向设计和实现了。
架构
我选择了 .NET 2.0、C# 和 Windows Forms,因为 .NET Framework/C# 开发起来(特别是 UI)非常简单快捷,Windows Forms 功能强大,并且 Visual Studio Express 是免费的。
架构图展示了 Practice# 的不同层。从上到下:
- 用户界面 - 负责用户界面逻辑,也负责控制核心音频逻辑(“后端”)层。
- 预设 - 一组用户定义的持久化设置,如速度、音高、音量等。
- 核心音频逻辑 - 包含 Practice# 正常运行所需的所有特定音频处理逻辑。控制和协调其他框架和库(NAudio、SoundTouch、Vorbis#)。不处理任何用户界面逻辑。
- NAudio - (第三方)音频播放平台,负责操作系统上音频播放所需的所有底层 API。
SoundTouchSharp
-SoundTouch
的托管 C# Interop 包装器。SoundTouch
- (第三方)音频 DSP 库,允许进行时间拉伸(速度更改)和音高更改。- Vorbis# - (第三方)库,读取 Ogg Vorbis 文件并返回压缩样本作为非压缩 PCM 样本,这些样本可以被 NAudio 处理。
LibFlacSharp
-libFlac
的托管 C# Interop 包装器。- libFlac - (第三方)C 库,读取 FLAC 文件并返回压缩样本作为非压缩 PCM 样本,这些样本可以被 NAudio 处理。
设计
时间拉伸
最大的问题是如何拉伸时间——如何在**不改变音高**的情况下改变音频速度(或速度)?这具体是什么意思?如果你有一个音频文件(例如 mp3 或 wav),然后通过每秒采样一个样本来加快播放速度,那么它的播放速度将是原始音频的两倍,但音高(声音的音调)会升高两倍——听起来像卡通片。当使用“朴素”的蛮力方法减慢音频文件时,也会发生同样的问题:音高会降低,例如,原本是女高音的歌手会变成男低音。这不好——声音应该保持相同的音高,只是速度慢一些。
一个类似的问题是如何在**不改变速度**的情况下改变音频的音高。这种用例不像前一种那么有用,但仍然可以在某些情况下使用——例如为了匹配音调。这两种用例——时间拉伸和音高变化——是相似的,因为它们只改变一个参数(时间或音高),而不影响另一个播放参数(分别是音高或时间)。
这个问题背后的理论非常有趣,但超出了本文的范围。对于那些想了解更多关于时间拉伸和音高变化背后理论的人,请参考 DSPDimension 上的页面:DSPDimension。该主题在那里得到了详细、全面的描述,并用音频示例比较了不同的算法。
时间拉伸库的基本要求是:
- 它必须是开源且采用 LGPL 许可证。
- 它必须能在 Windows 32/64 位上运行。
- 音频质量必须好。
- 必须提供一个合适的 API,并且它必须能与 .NET 良好地协同工作。优先考虑托管代码,但并非强制。
- 它必须具有高性能(低 CPU 使用率)和低延迟。高延迟对于批量实用程序来说是可以接受的,但前端练习播放实用程序必须具有低延迟。
SoundTouch
唯一符合这些要求的候选库是 SoundTouch。SoundTouch
是一个采用 LGPL 许可证的 C++ 库,它提供了一个执行时间拉伸和音高更改的 API。SoundTouch
的质量相当好,从这些 示例中可以听出来。这个库的主要挑战是如何从托管 .NET 应用程序中使用它,因为它是一个原生的 C++ DLL。
SoundTouchSharp
为了实现 .NET 和 SoundTouch
之间的集成,编写了一个包装器——它被称为 SoundTouchSharp
。基本上,SoundTouchSharp
是一个 C# Interop 类,它包装了 SoundTouch
C++ 原生 DLL,并将 DLL 的函数公开为 C# 托管 API。注意: SoundTouchSharp
可以在 Practice# 的范围之外使用,用于需要实现时间拉伸或音高更改的应用程序。例如,如果一个 ASP.NET Web 应用程序需要该功能,它可以与 SoundTouch
一起使用 SoundTouchSharp
。
主要的 API 方法是:
/// <summary>
/// Sets new tempo control value. Normal tempo = 1.0, smaller values
/// represent slower tempo, larger faster tempo.
/// </summary>
public void SetTempo(float newTempo)
/// <summary>
/// Adds 'numSamples' pcs of samples from the 'samples' memory position into
/// the input of the object. Notice that sample rate _has_to_ be set before
/// calling this function, otherwise throws a runtime_error exception.
/// </summary>
public void PutSamples(float[] pSamples, uint numSamples)
/// </summary>
/// Adjusts book-keeping so that given number of samples are removed from beginning of the
/// sample buffer without copying them anywhere.
///
/// Used to reduce the number of samples in the buffer when accessing
/// the sample buffer directly
/// with 'ptrBegin' function.
/// </summary>
public uint ReceiveSamples(float[] pOutBuffer, uint maxSamples)
SetTempo()
方法设置播放的速度(或快慢)。它应该在将样本放入 SoundTouch
队列之前设置。PutSamples()
方法将样本放入 SoundTouch
队列。要将这些样本作为时间拉伸(或音高更改)的样本取回,需要调用 ReceiveSamples()
方法。当速度不是 100% 时(即常规速度),重要的是要理解,由于时间拉伸的固有性质,接收到的样本数量与放入队列的样本数量不同。因此,调用 ReceiveSamples()
的客户端需要考虑到这一点——ReceiveSamples
需要被调用,直到 SoundTouch
中的内部缓冲区没有更多样本可返回。一次调用 PutSamples()
(X 个样本)可能需要多次调用 ReceiveSamples()
,具体取决于在 SetTempo()
中设置的速度值。
音频播放框架
我有一些在 Windows 上进行音频播放的经验,但那是在 C++ 和 DirectSound
中完成的,而且总体来说,直接处理 DirectSound
很麻烦。我的目标是编写一个练习工具,允许在不花费太多时间在核心技术(如播放声音)上的情况下进行练习。
DirectSound
也是非托管的。幸运的是,有一个非常好的托管库用于音频处理和播放——NAudio。NAudio 负责所有底层 API(如 DirectSound
),并提供了一个简单的界面,易于使用,同时也易于扩展。使用 NAudio,我在几分钟内就成功播放了声音文件,但对于动态时间拉伸的播放来说,这还不够。交互式练习工具的主要要求是能够即时更改速度。因此,需要一个特殊的音频处理器,能够处理不同速度的样本。
基于 NAudio 讨论站点上出现的一个想法,创建了 AdvancedBufferedWaveProvider
类。它管理一个音频缓冲区队列,每个缓冲区开始于不同的时间(CurrentTime
)。AdvancedBufferedWaveProvider
的队列中不会存放过多的音频缓冲区,新的音频缓冲区会根据需要随时动态添加到队列中,并带有动态时间拉伸参数。
这种技术允许用户即时更改音高并获得低延迟的声音变化。
Ogg Vorbis
我非常喜欢开源产品和开源格式。这就是为什么我对一个只能播放 WAV(未压缩且基本上是微软格式)或 MP3(压缩但专有)的 LGPL 工具感到奇怪。Ogg Vorbis 是压缩且免费的——为什么不使用它呢?不幸的是,NAudio(在撰写本文时)默认不支持 Ogg Vorbis 文件的播放。我不得不提出一些解决方案——幸运的是,有一个名为 Vorbis#(或 csvorbis)的 LGPL 库,它为托管代码提供了 Ogg Vorbis 播放支持。Vorbis# 是 Jorbis Java 库的移植版,而 Jorbis 库本身是 最初用 C 编写的 xiph orbis 解码器的移植版。为了让 Vorbis# 与 NAudio 一起工作,编写了一个程序集(项目):NAudioOggVorbis
。NAudioOggVorbis
封装了 Vorbis# 代码,并提供了一个 NAudio Ogg Vorbis 适配器类 OggVorbisFileReader
,它通过继承 NAudio 核心抽象类 WaveStream
来插入 NAudio。NAudio 负责处理音频播放逻辑,并在需要时指挥 OggVorbisFileReader
返回缓冲区和/或更改文件位置。OggVorbisFileReader
将这些请求委托给 Vorbis# 类 VorbisFile
,该类负责将压缩的 Ogg Vorbis 数据包解码为非压缩的 PCM 数据包。VorbisFile
是实现许多 Vorbis# 类的解码逻辑的高级包装器。将其用作单一入口点可以使 OggVorbisFileReader
中的客户端代码保持整洁。OggVorbisFileReader
使用 VorbisFile
解码下一个数据包的代码。
/// </summary>
/// Reads decompressed PCM data from our Ogg Vorbis file.
/// </summary>
public override int Read(byte[] sampleBuffer, int offset, int numBytes)
{
int bytesRead = 0;
lock (m_repositionLock)
{
// Read PCM bytes from the Ogg Vorbis File into the sample buffer
bytesRead = m_vorbisFile.read(sampleBuffer, numBytes,
_BIGENDIANREADMODE, _WORDREADMODE, _SGNEDREADMODE, null);
}
return bytesRead;
}
一旦 PCM 数据包返回到 NAudio,NAudio 就会像处理其他任何源一样播放它们——也就是说,NAudio 不知道这些数据包最初是 Ogg Vorbis 编码的数据包,它也不应该关心这个事实。这种方法被证明是快速且易于实现的——Ogg Vorbis 就像 WAV 和 MP3 文件一样播放(并减慢速度)。任务完成。
FLAC
通过添加 Ogg Vorbis 播放(后来还有 WMA 播放,细节省略)的成功激励,我决定继续添加对另一种文件格式(解码器)的支持——FLAC。Ogg Vorbis 是一种出色的开源音频格式,但它是无损的。如果能有一个无损音频编解码器(AKA FLAC),它也经过压缩,那就很好了。
FLAC 正在成为“发烧友”的“必选”格式——它的可用性(质量和文件大小方面)非常出色,并且解码速度非常快。
因此,经过一些谷歌搜索和研究,我偶然发现了一个非常好的演示(由 Stanimir Stoyanov 编写),展示了如何在 C# 中解码 FLAC 文件。该演示通过 P/Invoke 调用与官方 libFlac
C API 进行通信来解码 FLAC。
我使用了 Stan 的代码,添加了一些解码所需的缺失 API(例如,元数据,绝对寻址),并将其重构为一个新的 C# 托管集成层到 libFlac
API:LibFlacSharp
。我将解码器和编码器 API 放在一起,尽管 Practice# 只使用解码 API。
LibFlacSharp
是一个可以被任何 C# 客户端(不只是 Practice#)使用的类。
其主要的解码 API 是:(API 在 libFlac 网站上得到了很好的记录。)
/// <summary>
/// C# (.NET) Wrapper for the libFlac library (Written in C++)
/// </summary>
public class LibFLACSharp
{
...
public static extern IntPtr FLAC__stream_decoder_new();
public static extern bool
FLAC__stream_decoder_process_until_end_of_metadata(IntPtr context);
public static extern bool FLAC__stream_decoder_process_single(IntPtr context);
public static extern bool FLAC__stream_decoder_finish(IntPtr context);
...
}
我做的第二件事是编写了一个 NAudio 文件读取器适配器,它在某种程度上类似于 Ogg Vorbis,但更复杂。libFlac
以某种不等于 NAudio 缓冲区大小的帧大小返回帧。libFlac
也与回调一起工作,这本质上不如直接控制方便。回调是同步的,因为 libFlac
没有线程,但仍然有点麻烦。
我找到了一个优雅的解决方案来解决这些问题——FLAC 帧被读入一个中间样本缓冲区。然后,当 NAudio 需要新的样本来播放时,首先尝试从中间缓冲区拉取样本(如果那里有样本可用)。
在缓冲区样本使用完毕(如果使用的话)后,并且如果还有样本需要填充 NAudio 播放缓冲区,那么就会向 libFlac
(通过 LibFlacSharp
)发出请求,以获取一个额外的 FLAC 帧。
这种设计模式其实并不新鲜,但在 Practice# 的背景下,它让我想起了吹风笛……所以我将称之为**风笛设计模式**
就像真正吹风笛一样,风笛手会按照自己的意愿向气囊吹气(FLAC 帧),但实际演奏(NAudio 播放)是连续的,使用的是气囊中的空气(中间 FLAC 样本缓冲区)。
UI 设计
如上所述,我的目标是创建一个用户界面,该界面应具有高度的生产力和直观性,并且在精神上接近 Boss ME-70。我还喜欢其他一些元素,例如 Loop controls 和 Now buttons,它们受到了 BestPractice 工具设计的启发。使用的菜单很少(仅用于最近打开的文件、关于),并且只有三个模态对话框(打开文件、预设描述对话框和关于框)——工具的所有其他操作方面都直接显示在窗体上。4 个预设类似于 Boss 的 4 个预设踏板——一次只有一个是活动的。要写入预设,需要单击“写入”按钮(软盘图标)——此时,所有预设的 LED 会亮起红色,等待用户选择要写入的预设。单击预设后,预设设置将被写入文件。
要取消预设写入模式,只需再次单击“写入”按钮,所有预设 LED 都会恢复为绿色(常规模式)。在预设写入模式下,单击另一个预设相当于“复制预设”功能,因为当前预设的设置也将被写入选定的预设。
每个音频文件都会自动获取自己的预设文件——所有这些预设文件都保存在用户文件夹 %LOCALAPPDATA%\PracticeSharp 中(例如,在我 Win7 笔记本上,它是:C:\Users\Yuval\AppData\Local\PracticeSharp)。这有几个好处——每个文件都有自己的预设持久化,无需菜单,并且当文件重新打开时,正确的预设会自动加载。如果需要将预设重置为默认值,用户需要按住橡皮擦图标一秒钟或更长时间,当前预设将以橙色 LED 闪烁几次,然后所有设置将恢复为默认值。再次强调:没有菜单或模态对话框。
实现 - 音频处理
Practice# 的核心是 PracticeSharpLogic
类。它包含由 ProcessAudio
实现的音频处理线程,该线程执行以下操作:
- 从输入文件中读取未压缩样本的块。
- 使用
SoundTouch
处理样本。 - 从
SoundTouch
接收处理后的样本。 - 使用 NAudio 播放处理后的样本。
- 处理动态即时更改音量、循环、Cue 和当前播放位置所需的控制逻辑。
- 通过均衡器 DSP 效果处理样本,以实现均衡器效果。
第一个需要注意的重要代码是从输入文件中读取样本(如上面图片中的第一个绿色矩形所示)。这是通过 NAudio 的 WaveChannel
类实现的。注意:我使用了一个技巧,可以在不实际占用 CPU 或内存的情况下将 float
数组转换为 byte
数组。该类是 ByteAndFloatsConverter
,它基于 此讨论。
#region Read samples from file
// Change current play position
lock (m_currentPlayTimeLock)
{
if (m_newPlayTimeRequested)
{
m_waveChannel.CurrentTime = m_newPlayTime;
m_newPlayTimeRequested = false;
}
}
// *** Read one chunk from input file ***
bytesRead = m_waveChannel.Read(convertInputBuffer.Bytes, 0,
convertInputBuffer.Bytes.Length);
// **************************************
#endregion
第二个需要注意的重要代码是通过 SoundTouchSharp
将读取的样本放入 SoundTouch
进行 DSP 处理(如上面图片中的第二个绿色矩形所示)。在调用之前设置了所需的速度和音高(在 SetSoundSharpValues
中)。
#region Put samples in SoundTouch
SetSoundSharpValues();
// *** Put samples in SoundTouch ***
m_soundTouchSharp.PutSamples(convertInputBuffer.Floats, (uint)floatsRead);
// **********************************************************************
#endregion
最后,第三个也是也许最重要的代码是从 SoundTouch
接收处理后的样本,然后将其放入排队的缓冲播放器中,由 NAudio 播放(如上面图片中的第三个绿色矩形所示)。
#region Receive & Play Samples
// Receive samples from SoundTouch
do
{
// *** Receive samples back from SoundTouch ***
// *** This is where Time Stretching and Pitch Changing is done *********
samplesProcessed = m_soundTouchSharp.ReceiveSamples
(convertOutputBuffer.Floats, outBufferSizeFloats);
// **********************************************************************
if (samplesProcessed > 0)
{
TimeSpan currentBufferTime = m_waveChannel.CurrentTime;
// ** Play samples that came out of SoundTouch
// by adding them to AdvancedBufferedWaveProvider - the buffered player
m_inputProvider.AddSamples(convertOutputBuffer.Bytes, 0,
(int)samplesProcessed * sizeof(float) *
format.Channels, currentBufferTime);
// *********************************************************************
// Wait for queue to free up - only then add
// continue reading from the file
while (!m_stopWorker &&
m_inputProvider.GetQueueCount() > BusyQueuedBuffersThreshold)
{
Thread.Sleep(10);
}
bufferIndex++;
}
} while (!m_stopWorker && samplesProcessed != 0);
#endregion
使用演示
请参阅 YouTube 视频。
源代码
由于 CodeProject 不支持 Subversion,我决定将 Practice# 的源代码保存在 Google Code 上。要获取最新源代码,请检出 最新 Practice# 源代码。要获取最新二进制文件,请下载 最新 Practice# 二进制文件。
历史
1.6.4:
发布版本:2013/3/20
- SoundTouch 库已更新至 1.7(由库作者进行的优化和修复)。
- NAudio 库已更新至 1.6(由库作者进行的优化和修复)。
- 逻辑变更:Cue 现在发生在循环的前面,而不是末尾。
- 变更:音高分辨率现在是 1/4 个半音,而不是 1/2 个半音。
- Bug 修复
- 主要问题 - http://code.google.com/p/practicesharp/issues/detail?id=10
- 主要问题 - http://code.google.com/p/practicesharp/issues/detail?id=12
- http://code.google.com/p/practicesharp/source/detail?r=288
- http://code.google.com/p/practicesharp/source/detail?r=290
- http://code.google.com/p/practicesharp/source/detail?r=292
- http://code.google.com/p/practicesharp/issues/detail?id=8
- http://code.google.com/p/practicesharp/issues/detail?id=9
1.5.0:
发布:2012/3/2
- 修复了加载最近文件时出现的随机崩溃/挂起——DirectSound ? 在 NAudio 中不稳定,已移至 WaveOut ? (XP)或 Wasapi(Vista、7)。
- 修复了速度和音高滑块的鼠标行为。值现在已正确四舍五入,刻度线是“粘性”的。
- 添加了“显示技术日志”(F12)用例。如果由于某种原因工作不正常,它有助于查看和发送日志。
- 修复了加载现有文件后加载文件时出现的问题。曾短暂播放旧歌曲。(SoundTouch ? 缓冲区未正确刷新,留有一些剩余样本)。不影响稳定性,但很烦人。
- 新功能! 人声抑制(又称语音消除或卡拉 OK),注意:仅适用于立体声文件。
1.4.1:
发布:2012/2/10
- 8 个预设(而不是 4 个)。
- 键盘快捷键
- 添加了新的用例:预设快速写入(使用 Ctrl+W)。
- 改进了外观和感觉(玻璃按钮)。
- 音高更改为精确的半音间隔。
- 修复了 Loop 重置到开始的次要 bug。
1.3.0:
发布:2012/1/5
- 使用新版本的 NAudio 和 SoundTouch? 库重新编译。
- 支持 AIFF 播放。
- 图形略有更改。
1.2.0:
发布:2011/9/2
- 通过微调 SoundTouch 引擎改进了慢速播放质量。在播放速度减慢时,声音质量有了显著提高,特别是对于歌唱/语音部分,也适用于音乐。
- 现在使用手动设置,而不是 Sound Touch 自动提供的默认设置。
- 添加了
TimeStretchProfiles
,以支持 SoundTouch 引擎的自定义调优。 - 修复了
positionLabel
处理的次要 bug:在暂停模式下单击它不起作用。 - 位置重置(回到开始)键盘从 Home(默认由其他 TrackBars 使用)更改为 F5。
1.0.1:
发布:2011/1/22
- 注意:我很抱歉,但我发布了一个糟糕的 1.0 版本,1.0.1 取代了 1.0。
- 添加了 Wix/MSI 安装程序,带有 dotNetInstaller 引导程序。
- “Initialized”(已初始化)状态 -> 重命名为“Ready”(准备就绪)。
- 修复:应用程序加载时,前一个文件没有显示循环边界(“条”)。只有在播放文件后,它才会显示出来。
- 主要 bug 修复:停止音频未正确执行,导致线程问题(死锁)和/或崩溃(在我测试过的较慢的机器上尤为明显)。