从一次失误中注意到的 System.Media.SoundPlayer 流读取

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。查找方向如下:PlaySyncLoadAndPlayLoadSyncLoadStreamWorkerThread。然后就可以看到读取的核心:

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 加上后,果然可以正常播放了。

分享到 评论