在 kawashima 主体完工之后我转向了 C# 的实现。自然,接口风格就要用 Stream
的风格啦。我已经做好了 kawashima 的流式封装,接着来做纯 C# 版的。虽说过程中遇到了各种坑,但是最后基本上都过去了,实现了文件从 HCA 到 WAV 的解码(固定逻辑)。写了流式封装之后要进行测试,最好的测试方式就是用 System.Media.SoundPlayer
,它的一个构造重载接受一个 System.IO.Stream
类型的流,从中读取 WAV 数据,进而调用系统 API 播放。P/Invoke 版的就是这么测试的。
然而此次测试中发现一个奇怪的现象。新的 HcaAudioStream
支持从 HCA 到 WAV 的文件的解码,而且经过散列对比,这个文件和原始的 hca
生成的完全一致,SoundPlayer
也能播放,说明从流到流的解码应该是没问题。但是这个类直接传入 SoundPlayer
,就会在一段时间后(我用一个属性验证过,解码结束了)抛出 InvalidOperationException
,错误说明是“波形头已损坏”。对此我觉得很奇怪,文件能播放,直接传数据流为什么就不行呢?挖掘了一番,在 .NET Framework 中找到了一个错误。
不过,剧情再次反转,原来是我忘记使用了 offset
参数,而这个参数的作用……下文详细讲解。
总之这次给了我一个机会研究 SoundPlayer
的缓冲实现原理。
这是失败的代码:
using (var hca = new HcaAudioStream(fs, param)) {
using (var sp = new SoundPlayer(hca)) {
sp.LoadTimeout = 500 * 1000;
sp.PlaySync();
}
}
运行,解码完毕后(可以设置一个属性,报告目前解码了多少区块来确认)SoundPlayer
抛出了一个异常:
InvalidOperationException`:波形头已损坏。
这不是莫名其妙么?那么为什么我 P/Invoke 的版本工作正常呢?于是我又用了一个 MemoryStream
来包装:
int i = 0;
using (var hca = new HcaAudioStream(fs, param)) {
using (var ms = new MemoryStream()) {
var buffer = new byte[1024];
while (hca.CanRead) {
var len = hca.Read(buffer, 0, buffer.Length);
ms.Write(buffer, 0, len);
}
ms.Seek(0, SeekOrigin.Begin);
using (var sp = new SoundPlayer(ms)) {
sp.LoadTimeout = 500 * 1000;
sp.PlaySync();
}
}
}
更奇怪的是,这次居然是成功的!而且文件写入也证明,解码结果和 P/Invoke 版本完全一致,先解码到文件再用 FileStream
读取,也是可以播放的。
于是我做了一个假设。在之前的实验中,我知道 SoundPlayer
的源如果是个 Stream
,则会用一个长度为 1024 的字节数组作为缓冲区来读取流的内容。所有 Stream
都需要实现这样一个方法:int Read(byte[] buffer, int offset, int count)
,会不会是 SoundPlayer
忽略了其返回值呢?比如,假如流里还剩下 1000 字节,我返回 1000(读取到 1000 字节),如果 SoundPlayer
还按照读取到了整个缓冲区长度(1024 字节)来处理的话,在第一次读取波形头(长度 44 字节)的时候就会产生错误数据。而且,用 MemoryStream
的这个我控制的读取过程似乎证明了这一点。
但是推测还不够,要证明。于是我用 ILSpy,打开 GAC 内的 System.dll
(.NET Framework 4),找到 System.Media.SoundPlayer
。查找方向如下:PlaySync
→LoadAndPlay
→LoadSync
→LoadStream
→WorkerThread
。然后就可以看到读取的核心:
this.streamData = new byte[1024];
int i = this.stream.Read(this.streamData, this.currentPos, 1024);
int num = i;
while (i > 0)
{
this.currentPos += i;
if (this.streamData.Length < this.currentPos + 1024)
{
byte[] destinationArray = new byte[this.streamData.Length * 2];
Array.Copy(this.streamData, destinationArray, this.streamData.Length);
this.streamData = destinationArray;
}
i = this.stream.Read(this.streamData, this.currentPos, 1024);
num += i;
}
这一段和 List<T>
的数组分配策略很像。我注意到,这里并没有犯我所想象的简单错误。不过,它用了一个 currentPos
记录了当前最后有效数组元素的位置,并以此为依据进行数组扩充。在调用我的流的 Read()
函数的时候,streamData
永远是一个扩充好的(新)数组,而 currentPos
是永远递增的。问题就出在这里,我以为它用的会是一个固定缓冲区,这样 offset
会是循环的——而在我的例子中,其值恒为零。
我的实现也是有一个 Array.Copy()
的,从我的缓冲区复制到输出中。它的调用是这样的:
Array.Copy(source, sourceIndex, buffer, 0, availableByteCount);
这样复制到的永远是 buffer
的开头,后写的数据把前面的数据都覆盖了。这就是为什么会出现“波形头损坏”。将 offset
加上后,果然可以正常播放了。