为什么说Web Audio API的设计是愚蠢的

原文:I don’t know who the Web Audio API is designed for
作者:Jasper St. Pierre
翻译:lloog

译者注:作者介绍网络音频API现实存在的问题。

WebGL是一个不错的API。但不是一个很好的API,这是因为OpenGL不是一个很好的API。WebGL提供了对GPU的原始访问,但非常低级。对于那些被如此低层次的东西所吓倒的人来说,这也有相当多的高级引擎,比如three.jsUnity更容易使用。它们是很好的API,拥有强大的功能,它是我们在网络上使用GPU的最佳简便抽象的好方法。

HTML5 Canvas是一个相当不错的API。但它也有很多缺点:例如没有色彩空间,如果你不花费很大精力将它移植到SVG上,你就不能直接将DOM元素画在画布上,模糊被用户隐藏到“阴影”API和其他一些内容中。但它确实是绘制2D图形的一个很好的方法。

相反,Web Audio是一个我不明白的API。 Web Audio的范围绝对是巨大的,我无法想象任何人使用这功能,绝对昂贵的核心抽象和基本功能的缺失。引用规格本身:“这个规范的目标是包括现代游戏音频引擎中发现的功能以及现代桌面音频制作应用中的一些混合,处理和过滤任务。”

我无法想象任何想要使用Web Audio的任何高级功能的游戏引擎或音乐制作应用程序。像DynamicsCompressorNode这样的东西实际上就是一个笑话:基本上缺少真正的压缩器的基本功能,而且这个行为是不合适的,所以我甚至不敢相信它在浏览器之间是正确的。这样的过滤器可能会使用asm.js或WebAssembly进行编写,或者由于DSP的无输入,输入/输出性质而被运行为Web Workers。像这样的数学和紧凑的循环并不难,但它们不是火箭科学。这是确保正确行为的唯一方法。

对于那些想做这样的事情的人:计算我们的音频样本,然后再播放它,那么API使它几乎不可能以任何性能的方式进行。

对于那些用传统的声音API进行音频编程的人来说,你将有一个充满了样本的缓冲区。硬件扬声器贯穿于这些样品。当API认为它即将耗尽时,它就会进入程序并要求更多。这通常是通过叫做“环形缓冲区”的数据结构完成的,在这个结构中,我们让扬声器“追踪”应用程序正在写入缓冲区的样本。“读指针”和“写指针”之间的差距是很重要的:如果系统过载,扬声器就会耗尽,导致裂纹和其他工件,声音太大,声音会出现明显的滞后。

还有一些细节,比如我们每秒钟有多少个样本,或者“采样率”。现在,有两种常用的采样率:448000hz,现在大多数系统都使用它,44100Hz,虽然有点奇怪的数字,
但由于它在CD音频中的使用而流行起来(CDDA为什么是44100Hz ?)因为索尼(Sony)是参与CD的组织之一,它在一个早期的数字音频项目中,从u – matic磁带中抄袭了CDDA。通常情况下,操作系统必须在运行时转换为不同的采样率或“重新采样”音频。

这是一个理论上的非web音频API,计算和播放440Hz的正弦波

const frequency = 440; // 440Hz A note.
 // 1 channel (mono), 44100Hz sample rate
const stream = window.audio.newStream(1, 44100);
stream.onfillsamples = function(samples) {
    // The stream needs more samples!
    const startTime = stream.currentTime; // Time in seconds.
    for (var i = 0; i < samples.length; i++) {
        const t = startTime + (i / stream.sampleRate);
        // samples is an Int16Array
        samples[i] = Math.sin(t * frequency) * 0x7FFF;
    }
};
stream.play();

然而,在Web音频API中,上述情况几乎是不可能的。这是我能做的最接近的东西。

const frequency = 440;
const ctx = new AudioContext();
// Buffer size of 4096, 0 input channels, 1 output channel.
const scriptProcessorNode = ctx.createScriptProcessorNode(4096, 0, 1);
scriptProcessorNode.onaudioprocess = function(event) {
    const startTime = ctx.currentTime;
    const samples = event.outputBuffer.getChannelData(0);
    for (var i = 0; i < 4096; i++) {
        const t = startTime + (i / ctx.sampleRate);
        // samples is a Float32Array
        samples[i] = Math.sin(t * frequency);
    }
};
// Route it to the main output.
scriptProcessorNode.connect(ctx.destination);

似乎很相似,但有一些重要的区别。首先,这是不赞成的。是的。自2014年以来,音频工作者已经弃用ScriptProcessorNode。顺便说一下,音频工作者不存在。它们在任何浏览器中实现之前,它们被AudioWorklet API所取代,后者在浏览器中没有任何实现。

其次,整个上下文的采样率是全局的。没有办法让浏览器重新对动态生成的音频进行重新采样。尽管浏览器要求在C++中快速重新采样代码,但这并没有暴露在ScriptProcessorNode的用户中。一个音频环境的采样率并没有被定义为44100Hz或48000Hz。它不仅依赖于浏览器,还依赖于设备的操作系统和硬件。连接到蓝牙耳机可以使音频环境的采样率在没有任何警告的情况下发生变化。

所以ScriptProcessorNode是不可以的。然而,有一个API允许我们提供一个不同的采样缓冲区,并让Web Audio API发挥它的作用。然而,这并不是一种“拉动式”的方法,浏览器每次都要获取样本,而是一种“推送”式的方法,在这种方法中,我们经常会播放音频的新缓冲区。这就是所谓的BufferSourceNode,它是emscripten的SDL端口用来播放音频的。(他们曾经使用ScriptProcessorNode,但随后删除了它,因为它不太好)

让我们尝试使用BufferSourceNode来播放我们的正弦波:

const frequency = 440;
const ctx = new AudioContext();
let playTime = ctx.currentTime;
function pumpAudio() {
    // The rough idea here is that we buffer audio roughly a
    // second ahead of schedule and rely on AudioContext's
    // internal timekeeping to keep it gapless. playTime is
    // the time in seconds that our stream is currently
    // buffered to.

    // Buffer up audio for roughly a second in advance.
    while (playTime - ctx.currentTime < 1) {
        // 1 channel, buffer size of 4096, at
        // a 48KHz sampling rate.
        const buffer = ctx.createBuffer(1, 4096, 48000);
        const samples = buffer.getChannelData(0);
        for (let i = 0; i < 4096; i++) {
            const t = playTime + Math.sin(i / 48000);
            samples[i] = Math.sin(t * frequency);
        }

        // Play the buffer at some time in the future.
        const bsn = ctx.createBufferSource();
        bsn.buffer = buffer;
        bsn.connect(ctx.destination);
        // When a buffer is done playing, try to queue up
        // some more audio.
        bsn.onended = function() {
            pumpAudio();
        };
        bsn.start(playTime);
        // Advance our expected time.
        // (samples) / (samples per second) = seconds
        playTime += 4096 / 48000;
    }
}
pumpAudio();

这里有一些不好的消息。首先,我们基本上依靠浮点计时,在几秒钟内可以保持播放时间一致,无缝隙。但没有办法重置音频上下文的当前时间来构建一个新版本,因此如果有人想要构建一个能够存活数天的专业数字音频工作站,那么浮点精度损失将成为一个大问题。

其次,这也是ScriptProcessorNode的一个问题,示例数组中充满了浮点数。但是强迫每个人使用浮点数是很慢的。16位对于每个人来说都足够了,而且对于输出格式来说,它也已经足够了。整数运算单位是非常快的,没有什么大的理由将它们摆脱方程式。你可以将代码从一个浮点数转换为一个用于最终输出的int16,但是一旦某事物处于浮动状态,它将永远是缓慢的。

第三,最重要的是,我们为每个音频样本分配两个新对象!每个缓冲区大约85毫秒,所以每85毫秒,我们分配两个新的GC’d对象。如果我们可以使用我们切割的现有的大型ArrayBuffer,可以减轻这一点,但是我们不能提供我们自己的ArrayBuffer:让createBuffer为我们请求的每个通道创建一个缓冲区。你可能会想象,你可以使用一个非常大的空间来创建缓冲区,并只在BufferSourceNode中使用很小的一部分,但是没有办法对AudioBuffer对象进行切片,也没有办法在AudioBufferSourceNode对应的对象中指定一个偏移量。

您可能会认为最好的解决方案是简单地保留一个缓冲池对象池,并在它们完成之后对它们进行回收,但BufferSourceNode设计为一次性使用,即时消失的API。该文档还很好地说明了它们“创建简单”,而且“在适当的时候会自动收集垃圾信息”。

我知道我在这里打了一场艰苦的战斗,但我们在实时音频播放期间不需要GC。

根据Chrome分析器,尽管在我自己的测试应用程序中,在一个主要的GC擦除之前,我仍然会看到缓慢增长到12MB。

更讽刺的是,Mozilla提出了一个非常类似的API,叫做音频数据API。它有三个函数:它有三个功能:setup(),currentSampleOffset()和writeAudio()。它仍然是一个push API,不是一个pull API,但是它非常简单,在运行时支持重新采样,不需要您将其分解为GC缓冲区,并且没有任何内容。

规格和库不能在凭空创建。如果我们把最简单的接口放在那里,然后让人们在JavaScript(重采样,FFT)中实现,并将它们放在C ++中,我相信我们会看到比今天更多的增长和使用。而且我们将有这个API的实际用户,以及在生产中使用它的用户的真实世界反馈。相反,Web Audio的最大用户现在似乎是emscripten,他们显然不会关心任何图形路由的无稽之谈,并且已经尝试解决可怕的API本身。

网络音频的过度荒谬会被逆转吗?我们能否带回一个简单的“播放音频”API,一旦我们看到在野外发生的事情,就会意识到提升性能的重要性?我不知道,我不在这些委员会里,我甚至不在网络开发中工作,除了在晚上和周末闲逛,我当然没有时间和耐心去关注这些事情。

但我真的非常希望看到它发生。

本文文字及图片出自 CSDN

余下全文(1/3)
分享这篇文章:

请关注我们:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注