五、HTML5 视频和画布
到目前为止,本书中的视频一直被视为某种静态媒体。正如您所发现的,视频只不过是以特定速率呈现在屏幕上的一系列图像,用户与视频的唯一交互是点击控件和/或阅读脚本或字幕。除此之外,对于用户来说,除了坐下来欣赏节目之外,真的没有什么可以做的了。通过一点 JavaScript 和 HTML5 画布的使用,你可以让这个被动的媒体变得互动,更重要的是,把它变成一个创造性的媒体。这一切都始于一个非常重要的概念:成像。
当在屏幕上绘制图像时,HTML5 可以呈现两种图像类型:SVG(可缩放矢量图形)或光栅位图图形。简单地说,SVG 图像由点、线和空间填充组成。它们通常由代码驱动,由于它们的性质,它们是独立于设备的,这意味着它们可以在屏幕上调整大小和重新定位,而不会损失分辨率。
另一方面,光栅图形是基于像素的。它们本质上与屏幕中的像素相连。随着 HTML5 视频和 canvas 元素的出现,屏幕就像它的名字所暗示的那样:一个空白的画布,你可以在这里画任何东西,从直线到复杂的图形。
SVG 环境是处理基于矢量的形状的声明性图形环境,而 HTML canvas 提供了围绕像素或位图的基于脚本的图形环境。与 SVG 相比,在 canvas 中操作数据实体更快,因为直接访问单个像素更容易。另一方面,SVG 提供了一个 DOM(文档对象模型),并且有一个 canvas 没有的事件模型。这应该告诉您,需要交互式图形的应用通常会选择 SVG,而进行大量图像处理的应用通常会选择 canvas。两者中可用的变换和效果是相似的,可以用两者实现相同的视觉效果,但是需要不同的编程工作和潜在的不同性能。
当比较 SVG 和 canvas 的性能时,通常绘制大量对象最终会降低 SVG 的速度,因为 SVG 必须维护对对象的所有引用,而对于 canvas 来说,只需要照亮更多的像素。所以,当你有很多对象要画,而你继续访问单个对象并不重要,只是在画完像素后,你应该使用画布。
相比之下,画布绘制区域的大小对
的速度有着巨大的影响,因为它必须绘制更多的像素。所以,当你有一个很大的区域要覆盖少量的对象时,你应该使用 SVG。
请注意,canvas 和 SVG 之间的选择并不完全排斥。通过使用名为toDataURL()
的函数将画布转换成图像,可以将画布放入 SVG 图像中。例如,在为 SVG 图像绘制漂亮且重复的背景时,可以使用这种方法。在画布中绘制背景并通过toDataURL()
函数将其包含到 SVG 图像中可能更有效:这解释了为什么本章重点是画布。
像 SVG 一样,画布本质上是一种面向视觉的媒体——它与音频没有任何关系。当然,您可以通过简单地将
元素作为页面的一部分,将背景音乐与令人惊叹的图形显示结合起来。在 9elements ( http://9elements.com/io/?p=153
)可以找到音频和画布如何结合的惊人例子。该项目通过在音乐背景上使用彩色和动画圆圈,是一个令人惊叹的 Twitter 聊天可视化。
如果你已经有了 JavaScript 的经验,canvas 应该不会太难理解。它几乎就像一个具有绘图功能的 JavaScript 库。它特别支持以下功能类别:
- 画布处理:创建绘图区域,2D 上下文,保存并恢复状态。
- 画基本形状:矩形、路径、直线、圆弧、贝塞尔曲线、二次曲线。
- 绘图文本:绘图填充文本、描边文本、测量文本。
- 使用图像:创建、绘制、缩放和切片图像。
- 应用样式:颜色、填充样式、笔画样式、透明度、线条样式、渐变、阴影和图案。
- 应用变换:平移、旋转、缩放和变换矩阵。
- 合成:裁剪和重叠绘制合成。
- 应用动画:通过关联时间间隔和超时,随时间执行绘图功能。
首先,让我们在画布上处理视频。
画布中的视频
理解如何在画布中处理视频的第一步是从元素中提取像素数据,并将其“绘制”在画布元素上。就像任何伟大的艺术家面对空白的画布一样,我们需要在画布上绘制图像。
drawImage( )
drawImage()
函数接受一个视频元素以及一个图像或画布元素。清单 5-1 展示了如何在视频中直接使用它。您可以在http://html5videoguide.net
跟随示例。
清单 5-1 。将视频像素数据引入画布
<video controls autoplay height="240" width="360" >
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<canvas width="400" height="300" style="border: 1px solid black;">
</canvas>
<script>
var video, canvas, context;
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
video.addEventListener("timeupdate", paintFrame, false);
function paintFrame() {
context.drawImage(video, 0, 0, 160, 120);
}
</script>
HTML 标记很简单。它只包含我们正在绘制视频数据的元素和
元素。
JavaScript 相当简单。addEventListener
是关键。每次视频的currentTime
更新——timeupdate
——paintFrame
函数使用与getContext("2d")
对象关联的drawImage()
方法将捕获的像素绘制到画布上。如图 5-1 所示,这些像素被绘制在<画布>元素(0,0)的左上角,并填充一个 160 × 120 的空间。所有浏览器都支持此功能。
图 5-1 。每次发生 timeupdate 事件时,将视频绘制到画布中
您会注意到视频播放的帧速率高于画布。这是因为timeupdate
事件不会在视频的每一帧都触发。它每隔几帧就触发一次,大约每隔 100-250 毫秒。目前没有任何功能可以让您可靠地抓取每一帧。然而,我们可以使用requestAnimationFrame()
函数创建一个每次屏幕刷新时都会更新的绘画循环。在典型的浏览器中,这大约是每秒 60 次,鉴于大多数现代视频大约是每秒 30 帧,它应该获得大多数帧,如果不是所有帧的话。
在下一个例子中,我们使用play
事件在用户开始回放时启动绘画循环,并一直运行到视频暂停或结束。另一种选择是使用canplay
或loadeddata
事件独立于用户交互来启动显示。
此外,让我们让下一个例子更有趣一点。既然我们现在知道了如何捕捉视频帧并将其绘制到画布上,让我们开始处理这些数据。在清单 5-2 中,每次画布重绘时,我们在 x 轴和 y 轴上移动 10 个像素。
清单 5-2 。使用 requestAnimationFrame 将不同偏移量的视频帧绘制到画布中
<video controls autoplay height="240" width="360" >
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<canvas width="400" height="300" style="border: 1px solid black;">
</canvas>
<script>
var video, canvas, context;
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
video.addEventListener("play", paintFrame, false);
var x = 0, xpos = 10;
var y = 0, ypos = 10;
function paintFrame() {
context.drawImage(video, x, y, 160, 120);
if (x > 240) xpos = -10;
if (x < 0) xpos = 10;
x = x + xpos;
if (y > 180) ypos = -10;
if (y < 0) ypos = 10;
y = y + ypos;
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
</script>
如图 5-2 中的所示,结果可能相当有趣。视频本身似乎是画布上的画笔,它在画布上移动,将视频帧绘制到似乎是随机的位置。实际上,如果你仔细观察paintFrame()
函数,情况并非如此。每个图像的尺寸设置为 160 × 120,视频的运动由xpos
和ypos
值决定。每一个连续的帧都与前一个帧向左和向右偏移 10 个像素,直到它到达画布的边缘,这时偏移被取消。
图 5-2 。使用 Chrome 中的 requestAnimationFrame 函数将视频绘制到画布中
本例绘制的帧率等于requestAnimationFrame()
函数的帧率,通常为 60Hz。这意味着我们现在更新画布的频率甚至比更新视频帧的频率还要高。
由于requestAnimationFrame()
方法还是相当新的,在旧的浏览器(尤其是 IE10 和更低版本)中,你需要使用setTimeout()
而不是requestAnimationFrame()
在给定的时间间隔后从视频中重复抓取一帧。
因为setTimeout()
函数在给定的毫秒数后调用一个函数,并且我们通常以每秒 24 (PAL)或 30 (NTSC)帧的速度运行视频,所以 41 毫秒或 33 毫秒的超时会更合适。为了安全起见,你可能想使用与requestAnimationFrame()
相同的帧率,这相当于你典型的 60Hz 的屏幕刷新率。因此,将超时设置为 1000/60 = 16 毫秒,以达到类似于图 5-2 的效果。对于您的应用,您可能希望进一步降低频率,使您的 web 页面更少占用 CPU(中央处理器)资源。
当您开始尝试使用setTimeout()
函数时,您会注意到它允许我们以比原始视频和requestAnimationFrame()
允许的更高的帧速率将视频帧“渲染”到画布中。让我们以清单 5-2 中的例子为例,用setTimeout()
和一个 0 超时重写它,这样你就能明白我们的意思了(参见清单 5-3 )。
清单 5-3 。使用 setTimeout 将不同偏移量的视频帧绘制到画布中
<video controls autoplay height="240" width="360" >
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<canvas width="400" height="300" style="border: 1px solid black;">
</canvas>
<script>
var video, canvas, context;
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
video.addEventListener("play", paintFrame, false);
var x = 0, xpos = 10;
var y = 0, ypos = 10;
var count = 0;
function paintFrame() {
count++;
context.drawImage(video, x, y, 160, 120);
if (x > 240) xpos = -10;
if (x < 0) xpos = 10;
x = x + xpos;
if (y > 180) ypos = -10;
if (y < 0) ypos = 10;
y = y + ypos;
if (video.paused || video.ended) {
alert(count);
return;
}
setTimeout(function () {
paintFrame();
}, 0);
}
</script>
结果,如图 5-3 所示,起初可能会令人惊讶。我们看到比使用requestAnimationFrame()
方法更多的视频帧被渲染到画布中。当你进一步思考这个问题时,你会意识到我们所做的只是尽可能快地从视频中抓取一帧到画布中,而不去担心它是否是一个新的视频帧。视觉效果是,我们在画布中获得了比在视频中更高的帧速率。事实上,在我们的一台机器上的谷歌 Chrome 中,我们在画布上实现了 210 fps。请注意,您的屏幕不会以该帧速率进行渲染,但通常仍会以 60 fps 左右的速率进行渲染,但每次渲染时,画布都会放置三到四个新帧,因此它看起来比前一个版本快得多。
图 5-3 。使用 Chrome 中的 setTimeout 事件将视频绘制到画布中
如果你在各种各样的现代浏览器中尝试过这种方法,你可能会注意到,在完整的 6 秒钟的剪辑回放过程中,每种浏览器都设法绘制了不同数量的视频帧。这是因为他们的 JavaScript 引擎速度不一。他们甚至可能会在继续绘制更多帧之前在中间停留一会儿。这是因为如果有其他更高优先级的工作要做,浏览器有能力延迟一个setTimeout()
调用。requestAnimationFrame()
函数不会遇到这个问题,它保证了一个等距的递归渲染调用,从而避免了播放抖动。
注意虽然我们已经演示了一个例子,但不要忘记这是代码,代码的巧妙之处在于能够使用它。例如,像改变
xpos
和ypos
值这样简单的事情会产生与图中所示完全不同的结果。
扩展的 drawImage( )
到目前为止,我们已经使用了drawImage()
函数将从视频中提取的像素绘制到画布上。这幅图还包括画布为我们做的缩放,以将像素放入给定的宽度和高度尺寸中。还有一个版本的drawImage()
允许你从原始视频中提取一个矩形区域,并将其绘制到画布中的一个区域上。这种方法的一个例子是平铺,视频被分成多个矩形,并在矩形之间留有间隙。清单 5-4 展示了一个简单的实现。我们只展示了新的paintFrame()
函数,因为代码的其余部分与清单 5-2 中的相同。我们还选择了requestAnimationFrame()
版本的绘画,因为我们真的不需要以比视频更高的帧率进行绘画。
清单 5-4 。将视频平铺到画布中的简单实现
function paintFrame() {
in_w = 720; in_h = 480;
w = 360; h = 240;
// create 4x4 tiling
tiles = 4;
gap = 5;
for (x = 0; x < tiles; x++) {
for (y = 0; y < tiles; y++) {
context.drawImage(video, x*in_w/tiles, y*in_h/tiles,
in_w/tiles, in_h/tiles,
x*(w/tiles+gap), y*(h/tiles+gap),
w/tiles, h/tiles);
}
}
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
带有许多参数的drawImage()
函数允许从原始视频中的任何偏移中提取矩形区域,并将该像素数据绘制到画布中的任何缩放矩形区域中。图 5-4 显示了该功能的工作原理。如您所见,视频的特定区域取自源,并被绘制到画布中的特定区域。源和目的地的特定区域在drawimage()
参数中设置。
图 5-4 。使用 drawImage()将源视频中的矩形区域提取到画布中的缩放矩形区域中
参数如下:drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh)
(见图 5-4 )。在清单 5-4 中,参数用于将视频细分为图块,图块的大小由in_h/tiles
使用in_w/tiles
设置,其中in_w
和in_h
是所用视频文件的固有宽度和高度(即video.videoWidth
和video.videoHeight
)。然后用w/tiles
乘以h/tiles
缩放这些图块,其中w
和h
是画布中视频图像的缩放宽度和高度。然后,每个图块以 5 像素的间距放置在画布上。
注意重要的是你要明白,视频资源的固有宽度和高度用于从视频中提取区域,而不是视频元素中潜在的缩放视频。如果忽略这一点,您可能会计算缩放视频的宽度和高度,并提取错误的区域。还要注意,可以通过将提取的区域放入不同尺寸的目标矩形中来缩放它。
图 5-5 显示了运行清单 5-4 的结果。正如你所看到的,视频在一个 4 × 4 的网格上被分成一系列的小块,每个小块之间相隔 5 个像素。所有浏览器都显示相同的行为。
图 5-5 。在 Chrome 中将视频平铺到画布中,视频在左边,画布在右边
这种实现并不完全是最佳实践,因为我们对每个图块调用一次drawImage()
函数。如果您将变量tiles
设置为值32
,一些浏览器会跟不上画布渲染,画布中的帧速率会停滞不前。这是因为在setTimeout
函数期间,每次调用drawImage()
获取视频元素时,都会从视频中检索像素数据。结果是一个超负荷的浏览器。
有三种方法可以克服这一点。它们都依赖于通过画布将视频图像放入中间存储区域,并从那里重新绘制图像。在第一种方法中,你将抓取帧并重画它们,在第二种方法中,你将抓取帧并重画像素,在最后一种方法中,你将使用第二块画布进行像素操作。
抓帧
这种方法包括将视频像素绘制到画布中,然后用getImageData()
从画布中提取像素数据,再用putImageData()
将其写出。由于putImageData()
有参数再次只画出图片的一部分,你应该可以复制和上面一样的效果。下面是函数的签名:putImageData(imagedata, dx, dy [, dirtyx, dirtyy, dirtyw, dirtyh ])
。
不幸的是,这些参数与drawImage()
函数的参数不同。“脏”矩形从图像数据中定义要绘制的矩形(默认情况下是完整的图像)。则 dx 和 dy 允许将该矩形从其在 x 和 y 轴上的位置移动得更远。图像不会发生缩放。
你可以在清单 5-5 中看到代码——同样,只提供了paintFrame()
函数,因为其余部分与清单 5-2 相同。
清单 5-5 。使用 putImageData()在画布中重新实现视频平铺
function paintFrame() {
in_w = 720; in_h = 480;
w = 360; h = 240;
context.drawImage(video, 0, 0, in_w, in_h, 0, 0, w, h);
frame = context.getImageData(0, 0, w, h);
context.clearRect(0, 0, w, h);
// create 4x4 tiling
tiles = 4;
gap = 5;
for (x = 0; x < tiles; x++) {
for (y = 0; y < tiles; y++) {
context.putImageData(frame,
x*gap, y*gap,
x*w/tiles, y*h/tiles,
w/tiles, h/tiles);
}
}
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
在这个版本中,putImageData()
函数使用参数来指定绘图偏移量,包括视频帧的间隙和剪切矩形的大小。该帧已经通过getImageData()
作为调整大小的图像被接收。注意,用drawImage()
绘制的帧需要在用putImageData()
重新绘制之前清除,因为我们不会在 5 px 的间隙上进行绘制。图 5-6 显示了运行清单 5-5 的结果。
图 5-6 。试图使用putImageData()
将视频平铺到画布中
注意注意,您必须从 web 服务器上运行这个示例,而不是从本地计算机上的文件中运行。原因是
getImageData()
不能跨站点工作,安全检查将确保它只能在同一个 http 域上工作。这排除了本地文件访问。
像素绘画
第二种方法是手动执行剪切。由于我们已经通过getImageData()
获得了像素数据,我们可以自己创建每个图块,并使用仅带有偏移属性的putImageData()
来放置图块。清单 5-6 显示了这种情况下paintFrame()
函数的实现。
清单 5-6 。用 createImageData 在画布中重新实现视频平铺
function paintFrame() {
w = 360; h = 240;
context.drawImage(video, 0, 0, w, h);
frame = context.getImageData(0, 0, w, h);
context.clearRect(0, 0, w, h);
// create 15x15 tiling
tiles = 15;
gap = 2;
nw = w/tiles;
nh = h/tiles;
// Loop over the tiles
for (tx = 0; tx < tiles; tx++) {
for (ty = 0; ty < tiles; ty++) {
output = context.createImageData(nw, nh);
// Loop over each pixel of output file
for (x = 0; x < nw; x++) {
for (y = 0; y < nh; y++) {
// index in output image
i = x + nw*y;
// index in frame image
j = x + w*y + tx*nw + w*nh*ty;
// copy all the colours
for (c = 0; c < 4; c++) {
output.data[4*i+c] = frame.data[4*j+c];
}
}
}
// Draw the ImageData object.
context.putImageData(output, tx*(nw+gap), ty*(nh+gap));
}
}
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
首先,我们遍历每个图块,并调用createImageData()
来创建图块图像。为了用像素数据填充图块,我们循环遍历图块图像的像素,并从视频帧图像的相关像素进行填充。然后我们使用putImageData()
放置瓷砖。图 5-7 显示了 15 × 15 格子瓷砖的结果。
图 5-7 。试图在 Chrome 中使用 putImageData()将视频平铺到画布中
这显然可以通过只写单个图像并在我们写该图像时在瓦片之间放置间隙来改善。每个单幅图块都有一个图像的好处是,您可以更轻松地操作每个单幅图块,例如旋转、平移或缩放单幅图块,但是您需要管理单幅图块的列表(即,保存指向单幅图块的指针列表)。
注意您必须从 web 服务器上运行这个示例,而不是从本地计算机上的文件中运行。原因是
getImageData()
不能跨站点工作,安全检查将确保它只能在同一个 http 域上工作。这排除了本地文件访问。
草稿画布
最后一种方法是将带有drawImage()
的视频图像存储到一个中间画布中——我们称之为 scratch canvas,因为它的唯一目的是保存像素数据,并且它甚至不在屏幕上显示。一旦完成,您使用drawImage()
和来自草稿画布的输入在显示的画布上进行绘制。我们的期望是,画布中的图像已经是一种可以一片一片复制到显示的画布中的形式,而不是像以前的幼稚方法那样不断缩放。清单 5-7 中的代码展示了如何使用草稿画布。
清单 5-7 。使用临时画布在画布中重新实现视频平铺
<video controls autoplay height="240" width="360">
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<canvas width="400" height="300" style="border: 1px solid black;">
</canvas>
<canvas id="scratch" width="360" height="240" style="display: none;"></canvas>
<script>
var context, sctxt, video;
video = document.getElementsByTagName("video")[0];
canvases = document.getElementsByTagName("canvas");
canvas = canvases[0];
scratch = canvases[1];
context = canvas.getContext("2d");
sctxt = scratch.getContext("2d");
video.addEventListener("play", paintFrame, false);
function paintFrame() {
// set up scratch frames
w = 360; h = 240;
sctxt.drawImage(video, 0, 0, w, h);
// create 4x4 tiling
tiles = 4;
gap = 5;
tw = w/tiles; th = h/tiles;
for (x = 0; x < tiles; x++) {
for (y = 0; y < tiles; y++) {
context.drawImage(scratch, x*tw, y*th, tw, th,
x*(tw+gap), y*(th+gap), tw, th);
}
}
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
</script>
注意 HTML 中第二个带有id=``scratch
的画布。它必须设置得足够大,以便能够包含视频帧。如果您没有给它一个宽度和高度属性,它将默认为 300 × 150,您可能会丢失边缘周围的数据。这个草稿画布的目的是在视频帧被传递到画布之前接收和缩放视频帧。我们不想显示它,这就是为什么它被设置为display:none
。然后使用清单 5-4 中所示的扩展drawImage()
函数将图块绘制(参见图 5-8 )到显示的画布中。
图 5-8 。在 Chrome 中使用“scratch canvas”技术
注意这是最有效的平铺实现方式,因为它不需要重复复制视频中的帧,也不需要不断调整原始帧的大小。它也适用于所有浏览器,包括 IE。它也不需要在网络服务器上运行,这是一个额外的优势。
正如你可能已经收集到的,在画布上平铺视频提供了一些相当有趣的创意可能性。由于每个区块可以单独操作,因此每个区块可以使用不同的变换或其他技术。Sean Christmann 的“放大你的视频”展示了一个将平铺与其他画布效果(如变换)相结合的惊人例子(见http://craftymind.com/factory/html5video/CanvasVideo.html
)。当你点击视频时,该区域被平铺,平铺散开,如图 5-9 所示,产生了爆炸效果。
图 5-9 。平铺为你提供了一些严肃的创作可能性
式样
现在我们知道了如何在画布中处理视频,让我们对画布像素进行一些简单的操作,这会产生一些非常有趣的结果。我们将从使视频中的某些像素透明开始。
替换背景的像素透明度
在 HTML5 video 出现之前,Flash video 的标志之一是能够在动画或静态图像上使用 alpha 通道视频。这种技术不能在 HTML5 世界中使用,但是对画布的操作允许我们确定哪些颜色是透明的,并将画布覆盖在其他内容上。清单 5-8 显示了一个视频,其中除了白色之外的所有颜色在被投影到带有背景图像的画布上之前都是透明的。在浏览器中,像素由三种颜色组合而成:红色、绿色和蓝色。r、g 和 b 分量中的每一个可以具有 0 到 255 之间的值,相当于 0%到 100%的强度。当所有 rgb 值都为 0 时为黑色,当所有 RGB 值都为 1 时为白色。在清单 5-8 中,我们发现一个像素的 r、g 和 b 分量都在 180 以上,足够接近白色,所以我们也可以保留一些更“脏”的白色。
清单 5-8 。通过画布操作使视频中的某些颜色透明
function paintFrame() {
w = 360; h = 240;
context.drawImage(video, 0, 0, w, h);
frame = context.getImageData(0, 0, w, h);
context.clearRect(0, 0, w, h);
output = context.createImageData(w, h);
// Loop over each pixel of output file
for (x = 0; x < w; x++) {
for (y = 0; y < h; y++) {
// index in output image
i = x + w*y;
for (c = 0; c < 4; c++) {
output.data[4*i+c] = frame.data[4*i+c];
}
// make pixels transparent
r = frame.data[i * 4 + 0];
g = frame.data[i * 4 + 1];
b = frame.data[i * 4 + 2];
if (!(r > 180 && g > 180 && b > 180))
output.data[4*i + 3] = 0;
}
}
context.putImageData(output, 0, 0);
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
清单 5-8 显示了基本的绘画功能。页面的其余部分与清单 5-2 非常相似,只是在<画布>样式中添加了一个背景图像。所有像素都以完全相同的方式绘制,除了每个像素的第四个颜色通道设置为 0。这是a
通道,它决定了rbga
颜色模型的不透明度,所以我们将所有非白色的像素设为不透明。图 5-10 显示的结果是星星是仅存的不透明像素,在香港的图像上产生烟花的效果。
图 5-10 。将遮罩的视频投影到画布中的背景图像上
注意这种技术也可以应用于蓝屏或绿屏视频。在这种情况下,视频中构成纯蓝色或绿色背景的像素变成透明的。如果屏幕光线不均匀,这将不起作用。
缩放像素切片以获得 3D 效果
视频通常放置在 3D 显示器中,通过使用透视使它们看起来更像真实世界的屏幕。这需要将视频的形状缩放为梯形,其中宽度和高度都独立缩放。在画布中,您可以通过绘制不同高度的视频图片垂直切片并使用drawImage()
函数缩放宽度来实现这一效果。清单 5-9 展示了这种技术的一个很好的例子。
清单 5-9 。使用 3D 效果在 2D 画布中渲染视频
function paintFrame() {
// set up scratch frame
w = 270; h = 180;
sctxt.drawImage(video, 0, 0, w, h);
// width should be between -500 and +500
width = -250;
// right side scaling should be between 0 and 200%
scale = 2;
// canvas width and height
cw = 1000; ch = 400;
// number of columns to draw
columns = Math.abs(width);
// display the picture mirrored?
mirror = (width > 0) ? 1 : -1;
// origin of the output picture
ox = cw/2; oy= (ch-h)/2;
// slice width
sw = columns/w;
// slice height increase steps
sh = (h*scale-h)/columns;
// Loop over each pixel column of the output picture
for (x = 0; x < w; x++) {
// place output columns
dx = ox + mirror*x*sw;
dy = oy - x*sh/2;
// scale output columns
dw = sw;
dh = h + x*sh;
// draw the pixel column
context.drawImage(scratch, x, 0, 1, h, dx, dy, dw, dh);
}
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
在这个例子中,只显示了paintFrame()
函数,我们使用了一个 1000×400 的画布和一个 scratch 画布,我们将像素数据放入其中。
当我们将视频帧拖入草稿画布时,我们将视频缩放到我们想要应用效果的大小。然后,我们将像素一列一列地拉到显示的画布中。这样做的时候,我们将像素列的宽度和高度缩放到输出图像所需的“宽度”和高度。输出图像的宽度通过width
变量给出。输出图像的高度在输出图像左侧的原始高度和右侧的原始高度的scale
倍之间缩放。负宽度将确定我们正在通过视频的“背面”观看。
这个例子是以这样一种方式编写的,你可以通过简单地改变width
和scale
变量来实现无数的创造性效果。比如你可以通过同步改变width
和scale
的值来达到翻书的效果。
图 5-11 显示了 Chrome 中的结果。包括 IE 在内的所有浏览器都支持这个例子,并且会显示相同的结果。
图 5-11 。在 Chrome 中以 3D 视角呈现视频
环境 CSS 颜色框架
画布可以用于的另一个很好的效果通常被称为视频的环境色帧。在这种效果下,会在视频周围创建一个彩色帧或边框区域,并且该帧的颜色会根据视频的平均颜色进行调整。
如果您的视频需要在页面上添加边框,或者您希望它引人注目,这种技术尤其有效。为此,您将经常计算视频的平均颜色,并使用它来填充视频后面的一个比视频稍大的 div。清单 5-10 展示了这种技术的一个实现例子。
清单 5-10 。画布中平均颜色的计算和环境色框的显示
<style type="text/css">
#ambience {
transition-property: all;
transition-duration: 1s;
transition-timing-function: linear;
padding: 40px;
width: 366px;
outline: black solid 10px;
}
video {
padding: 3px;
background-color: white;
}
canvas {
display: none;
}
</style>
<div id="ambience">
<video controls autoplay height="240" width="360">
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
</div>
<canvas id="scratch" width="320" height="160"></canvas>
</div>
<script>
var sctxt, video, ambience;
ambience = document.getElementById("ambience");
video = document.getElementsByTagName("video")[0];
scratch = document.getElementById("scratch");
sctxt = scratch.getContext("2d");
video.addEventListener("play", paintAmbience, false);
function paintAmbience() {
// set up scratch frame
sctxt.drawImage(video, 0, 0, 360, 240);
frame = sctxt.getImageData(0, 0, 360, 240);
// get average color for frame and transition to it
color = getColorAvg(frame);
ambience.style.backgroundColor =
’rgb(’+color[0]+’,’+color[1]+’,’+color[2]+’)’;
if (video.paused || video.ended) {
return;
}
// don’t do it more often than once a second
setTimeout(function () {
paintAmbience();
}, 1000);
}
function getColorAvg(frame) {
r = 0;
g = 0;
b = 0;
// calculate average color from image in canvas
for (var i = 0; i < frame.data.length; i += 4) {
r += frame.data[i];
g += frame.data[i + 1];
b += frame.data[i + 2];
}
r = Math.ceil(r / (frame.data.length / 4));
g = Math.ceil(g / (frame.data.length / 4));
b = Math.ceil(b / (frame.data.length / 4));
return Array(r, g, b);
}
</script>
尽管前面的代码块看起来相当复杂,但也相当容易理解。
我们首先设置 CSS 样式的环境,这样视频就被放在一个单独的
元素中,它的背景色从白色开始,但是会随着视频的播放而改变。视频本身有一个 3 px 的白色填充帧,将它与变色的分开。
由于有了setTimeout()
功能,视频周围的颜色每秒钟只会改变一次。我们决定在这个例子中使用setTimeout()
而不是requestAnimationFrame()
,以减少围绕视频的取景。为了确保平滑的颜色过渡,我们使用 CSS 过渡在一秒钟内完成改变。
正在使用的画布是不可见的,因为它仅用于每秒拉出一个图像帧并计算该帧的平均颜色。然后用那个颜色更新
的背景。图 5-12 显示了结果。
图 5-12 。Opera 中环境 CSS 颜色帧的渲染
如果你正在阅读印刷版,在图 5-12 中,你可能只看到一个深灰色的视频背景。然而,颜色实际上变成了背景中占主导地位的棕色的各种阴影。
注意尽管这项技术属于“酷技术”的范畴,但还是要谨慎使用。如果有一个令人信服的设计或品牌的理由来使用它,无论如何都要使用它。仅仅因为“我能”而使用它不是一个有效的理由。
作为模式的视频
画布提供了一个简单的功能来创建平铺有图像、另一个画布或视频帧的区域。功能是createPattern()
。这将获取一个图像并将其复制到给定区域,直到该区域充满图像或视频的副本。如果您的视频没有达到您的模式要求的大小,您将需要首先使用一个草稿画布来调整视频帧的大小。
清单 5-11 展示了它是如何完成的。
清单 5-11 。用视频图案填充矩形画布区域
<video autoplay style="display: none;" >
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<canvas width="720" height="480" style="border: 1px solid black;">
</canvas>
<canvas id="scratch" width="180" height="120" style="display:none;">
</canvas>
<script>
var context, sctxt, video;
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
scratch = document.getElementById("scratch");
sctxt = scratch.getContext("2d");
video.addEventListener("play", paintFrame, false);
function paintFrame() {
sctxt.drawImage(video, 0, 0, 180, 120);
pattern = context.createPattern(scratch, ’repeat’);
context.fillStyle = pattern;
context.fillRect(0, 0, 720, 480);
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
</script>
我们隐藏了原始的视频元素,因为视频已经在输出画布上绘制了 16 次。草稿画布大约每 16 毫秒抓取一帧(假设requestAnimationFrame()
以 60 fps 运行),然后使用createPattern()
的“重复”模式将其绘制到输出画布中。
每次调用paintFrame()
函数时,视频中的当前图像被抓取并用作createPattern()
中的复制图案。HTML5 canvas 规范声明,如果图像(或画布帧或视频帧)在使用它的createPattern()
函数调用后被改变,模式不会受到影响。
我们知道无法指定正在使用的图案图像的缩放比例,因此我们必须首先将视频帧加载到草稿画布中,然后从这个草稿画布中创建图案,并将其应用到绘图区域。
图 5-13 显示了 Safari 中的结果。因为所有浏览器都显示相同的行为,所以这代表了所有浏览器。
图 5-13 。在 Safari 中渲染视频模式
渐变透明蒙版
渐变遮罩用于逐渐淡化对象的不透明度。尽管市场上几乎每个视频编辑应用都广泛提供透明遮罩,但渐变遮罩也可以在运行时以编程方式添加。这是通过将页面内容(让我们假设一个图像)放在视频下面并在视频上应用灰度渐变来实现的。使用 CSS mask
属性,我们可以对渐变不透明的灰度蒙版应用透明度。我们也可以使用画布来完成这项工作。
使用 canvas,我们有更多的灵活性,因为我们可以在渐变中使用像素的 rgba 值。在这个例子中,我们简单地重用了前面的代码块,并将视频绘制到画布的中间。通过使用径向渐变,视频被混合到环境背景中。
清单 5-12 显示了代码的关键元素。
清单 5-12 。将渐变透明标记引入环境视频
<style type="text/css">
#ambience {
transition-property: all;
transition-duration: 1s;
transition-timing-function: linear;
width: 420px; height: 300px;
outline: black solid 10px;
}
#canvas {
position: relative;
left: 30px; top: 30px;
}
</style>
<div id="ambience">
<canvas id="canvas" width="360" height="240"></canvas>
</div>
<video autoplay style="display: none;">
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<canvas id="scratch" width="360" height="240" style="display: none;">
</canvas>
<script>
var context, sctxt, video, ambience;
ambience = document.getElementById("ambience");
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
context.globalCompositeOperation = "destination-in";
scratch = document.getElementById("scratch");
sctxt = scratch.getContext("2d");
gradient = context.createRadialGradient(180,120,0, 180,120,180);
gradient.addColorStop(0, "rgba( 255, 255, 255, 1)");
gradient.addColorStop(0.7, "rgba( 125, 125, 125, 0.8)");
gradient.addColorStop(1, "rgba( 0, 0, 0, 0)");
video.addEventListener("play", paintAmbience, false);
function paintAmbience() {
// set up scratch frame
sctxt.drawImage(video, 0, 0, 360, 240);
// get average color for frame and transition to it
frame = sctxt.getImageData(0, 0, 360, 240);
color = getColorAvg(frame);
ambience.style.backgroundColor =
’rgba(’+color[0]+’,’+color[1]+’,’+color[2]+’,0.8)’;
// paint video image
context.putImageData(frame, 0, 0);
// throw gradient onto canvas
context.fillStyle = gradient;
context.fillRect(0, 0, 360, 240);
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintAmbience);
}
</script>
我们不重复在清单 5-10 中定义的getColorAvg()
函数。
我们通过将显示画布的globalCompositeOperation
属性更改为destination-in
来实现带有渐变的视频遮罩。这意味着我们能够使用放置在视频帧上的渐变来控制视频帧像素的透明度。在这种情况下,我们在initCanvas
函数中使用径向渐变,并在每个视频帧中重复使用。
图 5-14 显示了所有浏览器中的结果。
图 5-14 。在各种浏览器中将带有透明遮罩的视频渲染到环境色帧上
剪辑一个区域
另一个有用的合成效果是从画布中剪切出一个区域进行显示。这将导致随后绘制到画布上的所有其他内容都只在剪裁区域中绘制。对于这种技术,一条路径被“画”出来,它可能也包括基本的形状。然后,我们不再使用 stroke()或 fill()方法在画布上绘制这些路径,而是使用clip()
方法绘制它们,在画布上创建裁剪区域,进一步的绘制将被限制在该区域内。清单 5-13 显示了一个例子。
清单 5-13 。使用剪切路径过滤出视频区域以供显示
<canvas id="canvas" width="360" height="240"></canvas>
<video autoplay style="display: none;">
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<script>
var canvas, context, video;
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
context.beginPath();
// speech bubble
context.moveTo(75,25);
context.quadraticCurveTo(25,25,25,62.5);
context.quadraticCurveTo(25,100,50,100);
context.quadraticCurveTo(100,120,100,125);
context.quadraticCurveTo(90,120,65,100);
context.quadraticCurveTo(125,100,125,62.5);
context.quadraticCurveTo(125,25,75,25);
// outer circle
context.arc(180,90,50,0,Math.PI*2,true);
context.moveTo(215,90);
// mouth
context.arc(180,90,30,0,Math.PI,false);
context.moveTo(170,65);
// eyes
context.arc(165,65,5,0,Math.PI*2,false);
context.arc(195,65,5,0,Math.PI*2,false);
context.clip();
video.addEventListener("play", drawFrame, false);
function drawFrame() {
context.drawImage(video, 0, 0, 360, 240);
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(drawFrame);
}
</script>
在这个例子中,我们不显示视频元素,而只在画布上绘制它的帧。在画布的设置过程中,我们定义了一个由一个语音气泡和一个笑脸组成的剪辑路径。然后,我们为play
事件设置事件监听器,并开始回放视频。在回调中,我们只需要将视频帧绘制到画布上。
这是一个非常简单有效的方法来掩盖视频区域。图 5-15 显示了 Chrome 中的结果。它在所有浏览器中都以同样的方式工作,包括 IE。
图 5-15 。在谷歌浏览器的裁剪过的画布上渲染视频
注意记住这个例子使用了一个相当简单的编程绘制的形状来屏蔽视频。使用标志或复杂的形状来达到同样的效果是一项艰巨的任务。
绘图文本
正如您在前面的示例中看到的,简单的形状可以用于创建视频遮罩。我们也可以使用文本作为视频的遮罩。这种技术非常简单,既易于可视化(文本颜色被视频取代),也易于实现。清单 5-14 展示了如何用画布来完成。
清单 5-14 。充满视频的文本
<canvas id="canvas" width="360" height="240"></canvas>
<video autoplay style="display: none;">
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<script>
var canvas, context, video;
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
// paint text onto canvas as mask
context.font = ’bold 70px sans-serif’;
context.textBaseline = ’top’;
context.fillText(’Hello World!’, 0, 0, 320);
context.globalCompositeOperation = "source-in";
video.addEventListener("play", paintFrame, false);
function paintFrame() {
context.drawImage(video, 0, 0, 360, 240);
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
</script>
我们有一个目标画布和一个隐藏的视频元素。在 JavaScript 中,我们首先将文本绘制到画布上。然后,我们使用globalCompositeOperation
属性将文本用作随后绘制到画布上的所有视频帧的遮罩。
注意,我们使用了source-in
作为合成函数。这适用于除 Opera 之外的所有浏览器,Opera 只是简单地绘制文本,但之后会忽略fillText()
剪切部分,再次绘制完整的视频帧。图 5-16 显示了所有支持该功能的其他浏览器的结果。
图 5-16 。在谷歌浏览器中作为文本填充的视频渲染
转换
canvas 也支持 CSS 支持的常见转换。这些 CSS 变换包括平移、旋转、缩放和变换矩阵。我们可以将它们应用到从视频中提取的帧上,给视频一些特殊的效果。
反思
网页设计者和开发者常用的一种视觉效果是倒影。反射实现起来相对简单,而且非常有效,尤其是在黑暗的背景下。你需要做的就是将视频帧拷贝到源视频下面的画布上,翻转拷贝,降低其不透明度,并添加渐变,这些我们之前都学过。
如果我们能够使用使用box-reflect
属性的纯 CSS 方法来创建反射,那肯定会更容易。不幸的是,这个属性还没有标准化,因此只有 blink 和基于 webkit 的浏览器实现了它。这是坏消息。
好消息是帆布来救援了。通过使用画布,我们可以在跨浏览器环境中创建一致的反射,同时保持复制的视频和源视频同步。
清单 5-15 是一个适用于所有浏览器的例子。
清单 5-15 。使用画布的视频反射
<div style="padding: 50px; background-color: #090909;">
<video autoplay style="vertical-align: bottom;" width="360">
<source src="video/HelloWorld.mp4" type="video/mp4">
<source src="video/HelloWorld.webm" type="video/webm">
</video>
<br/>
<canvas id="reflection" width="360" height="55"
style="vertical-align: top;"></canvas>
</div>
<script>
var context, rctxt, video;
video = document.getElementsByTagName("video")[0];
reflection = document.getElementById("reflection");
rctxt = reflection.getContext("2d");
// flip canvas
rctxt.translate(0,160);
rctxt.scale(1,-1);
// create gradient
gradient = rctxt.createLinearGradient(0, 105, 0, 160);
gradient.addColorStop(0, "rgba(255, 255, 255, 1.0)");
gradient.addColorStop(1, "rgba(255, 255, 255, 0.3)");
rctxt.fillStyle = gradient;
rctxt.rect(0, 105, 360, 160);
video.addEventListener("play", paintFrame, false);
function paintFrame() {
// draw frame, and fill with the opacity gradient mask
rctxt.drawImage(video, 0, 0, 360, 160);
rctxt.globalCompositeOperation = "destination-out";
rctxt.fill();
// restore composition operation for next frame draw
rctxt.globalCompositeOperation = "source-over";
if (video.paused || video.ended) {
return;
}
requestAnimationFrame(paintFrame);
}
</script>
注意这个例子使用了<视频>元素来显示视频,尽管第二块画布也可以用于这个目的。如果您采用这种方法,请确保移除
@controls
属性,因为它会破坏反射感知。
该示例将视频和视频下方对齐的画布放入一个深色的
元素中,为倒影提供一些对比度。同样,确保给和
元素相同的宽度。虽然,在这个例子中,我们给反射的高度是原始视频的三分之一。
当我们设置画布时,我们使用scale()
和translate()
函数将其准备为镜像绘图区域。平移会将其沿视频高度向下移动,缩放会沿 x 轴镜像像素。然后,我们在镜像画布上的视频帧的底部 55 个像素上设置渐变。
paintFrame()
功能在视频开始播放后并以最大速度播放时应用反射效果。因为我们已经决定让元素显示视频,所以
可能跟不上显示,这会导致和它的反射之间有一点时间上的脱节。如果这让你感到困扰,解决方案是通过另一个画布“绘制”视频帧,并隐藏视频本身。您只需要设置第二个
元素,并在paintFrame()
函数上方的画布中添加一个 drawImage()
函数。
对于反射,我们将视频帧“绘制”到镜像画布上。当使用两个
元素时,您可能会尝试使用getImageData()
和putImageData()
来应用画布转换。但是,画布变换不适用于这些函数。你必须使用一个画布,你已经通过drawImage()
将视频数据拉进画布来应用变换。
现在我们只需要镜像图像的渐变。
为了应用梯度,我们使用视频图像的梯度的合成函数。我们之前已经使用合成将画布中的当前图像替换为下一个图像。创建新的合成属性会改变这一点。因此,我们需要在应用渐变后重置合成属性。另一个解决方案是在改变合成属性之前和应用渐变之后使用save()
和restore()
函数。如果你改变了不止一个画布属性,或者你不想知道你必须重新设置属性的先前值,使用save()
和restore()
确实是更好的方法。
图 5-17 显示了最终的效果图。
图 5-17 。具有反射的视频渲染
螺旋视频
画布变换可以使我们在本章开始时看到的基于像素的操作变得容易得多,特别是当您想要将它们应用到整个画布时。在列表 5-2 和图 5-2 中显示的例子也可以用translate()
函数来实现,除了你仍然需要计算何时点击画布的边界来改变你的translate()
函数。这是通过添加一个translate(xpos,ypos)
函数来实现的,并且总是在位置(0,0)绘制图像,这并不十分成功。
我们想在这里看一个使用转换的更复杂的例子。我们将使用一个translate()
和一个rotate()
来使视频的帧在画布上盘旋。清单 5-16 展示了我们是如何做到这一点的。
清单 5-16 。使用画布的视频螺旋
<script>
var context, canvas, video;
var i = 0;
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
// provide a shadow
context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = "rgba(0, 0, 0, 0.5)";
video.addEventListener("play", paintFrame, false);
function paintFrame() {
context.drawImage(video, 0, 0, 120, 80);
context.setTransform(1, 0,
0, 1,
0, 0);
i += 1;
context.translate(3 * i , 1.5 * i);
context.rotate(0.2 * i);
if (video.paused || video.ended) {
alert(i);
return;
}
requestAnimationFrame(paintFrame);
}
</script>
和
元素定义与之前的例子没有变化。我们只需要增加画布的大小来适应整个螺旋。我们也给画在画布上的帧添加了阴影,这使它们与之前画的帧产生了偏移。
注意Chrome 中视频元素附加的阴影目前不起作用。谷歌正在解决这个问题。
我们绘制螺旋的方式是这样的,我们在平移和旋转的画布上绘制新的视频帧。为了将平移和旋转应用于正确的像素,我们需要在绘制一帧后重置变换矩阵。
这非常重要,因为先前的转换已经为画布存储,这样另一个调用——例如对translate()
的调用——将沿着旋转设置的倾斜轴进行,而不是像您预期的那样直线下降。因此,必须重置变换矩阵;否则,操作是累积的。
我们还计算了显示的帧数,以便比较不同浏览器的性能。如果你把视频从头到尾播放一遍,你会看到一个带有该数字的警告框以供比较。
图 5-18 显示了 Firefox 中的渲染结果。
图 5-18 。Firefox 中螺旋形视频帧的渲染
您可能会认为这很棒,但是这给浏览器带来了什么样的性能“冲击”呢?我们来做一个小的浏览器之间的性能比较。
视频文件的持续时间为 6.06 秒。requestAnimationFrame()
功能以 60Hz 探测视频,因此理论上在视频持续时间内拾取大约 363 帧。Chrome、Safari、Opera 和 IE 都实现了这么多帧的渲染。Firefox 只能达到 165 帧。经过一些实验后,发现画布的大小是问题所在——drawImage()
要绘制的画布越大,Firefox 就越慢。我们希望这只是我们观察到的暂时问题。
这一比较是在没有为图形操作设置额外硬件加速的情况下,在 Mac OS X 上下载并安装的浏览器上进行的。
结果是,使用这种技术必须有一个合理的理由,因为您无法控制用户选择哪种浏览器。
动画和交互性
我们已经使用requestAnimationFrame()
和setTimeout()
通过画布从视频帧中创建与视频时间轴同步的动画图形。在这一节,我们想看看另一种方式来制作画布动画:通过用户交互。
这里的关键“要点”是画布只知道像素,没有对象的概念。因此,它不能将事件与绘图中的特定形状相关联。然而,作为一个整体,画布接受事件,这意味着您可以将一个click
事件附加到
元素,然后将点击事件的[
x,y
]坐标与画布的坐标进行比较,以确定它可能与哪个对象相关。
在这一节中,我们将看一个有点像简单游戏的例子。通过单击开始播放视频后,您可以随时再次单击以从引用集合中检索引用。就当是一场幸运饼干赌博吧。清单 5-17 显示了我们是如何做到的。
清单 5-17 。在画布中与用户互动的幸运饼干和视频
<script>
var quotes = ["Of those who say nothing,/ few are silent.",
"Man is born to live,/ not to prepare for life.",
"Time sneaks up on you/ like a windshield on a bug.",
"Simplicity is the/ peak of civilization.",
"Only I can change my life./ No one can do it for me."];
var canvas, context, video;
var w = 640, h = 320;
video = document.getElementsByTagName("video")[0];
canvas = document.getElementsByTagName("canvas")[0];
context = canvas.getContext("2d");
context.lineWidth = 5;
context.font = ’bold 25px sans-serif’;
context.fillText(’Click me!’, w/4+20, h/2, w/2);
context.strokeRect(w/16,h/4,w*7/8,h/2);
canvas.addEventListener("click", procClick, false);
video.addEventListener("play", paintFrame, false);
video.addEventListener("pause", showRect, false);
function paintFrame() {
if (video.paused || video.ended) {
return;
}
context.drawImage(video, 0, 0, w, h);
context.strokeStyle=’white’;
context.strokeRect(w/16,h/4,w*7/8,h/2);
requestAnimationFrame(paintFrame);
}
function isPlaying(video) {
return (!video.paused && !video.ended);
}
function showRect(e) {
context.clearRect(w/16,h/4,w*7/8,h/2);
quote = quotes[Math.floor(Math.random()*quotes.length)].split("/");
context.fillStyle = ’blue’;
context.fillText(quote[0], w/4+5, h/2-10, w/2-10);
context.fillText(quote[1], w/4+5, h/2+30, w/2-10);
context.fillStyle = ’white’;
context.fillText("click again",w/10,h/8);
}
function procClick(e) {
var pos = canvasPosition(e);
if ((pos[0] < w/4) || (pos[0] > 3*w/4)) return;
if ((pos[1] < h/4) || (pos[1] > 3*h/4)) return;
!isPlaying(video) ? video.play() : video.pause();
}
</script>
在这个例子中,我们使用一组引号作为显示的“幸运 cookies”的来源。请注意,字符串中有一个“/”标记,用于将字符串分成多行。这样做是为了便于在单个字符串中存储。
我们继续设置一个空画布,其中有一个矩形,文本为:"Click me
!".回调是为画布上的click
事件注册的,也为视频上的pause
和play
事件注册的。诀窍是使用“click”回调来暂停和播放视频,这将触发与视频暂停和播放事件相关联的相应效果。我们将可点击区域限制为矩形区域,以展示区域如何在画布中进行交互,即使不知道有什么形状。
pause
事件触发在视频中间的矩形区域显示幸运饼。play
事件触发视频帧的继续显示,从而清除了幸运 cookie。注意,如果视频暂停,我们在paintFrame()
不做任何事情。这将处理从setTimeout()
函数到paintFrame()
的任何潜在排队调用。
你可能已经注意到我们在上面的例子中缺少了一个函数——函数canvasPosition()
和函数。这个函数有助于获得画布中单击的 x 和 y 坐标。它已经被提取到清单 5-18 (你可以在http://diveintohtml5.org/canvas.html
)
找到这个例子,因为它将是任何使用 canvas 进行交互工作的人的忠实伴侣。
清单 5-18 。获取画布中点击的 x 和 y 坐标的典型函数
function canvasPosition(e) {
// from http://www.naslu.com/resource.aspx?id=460
// and http://diveintohtml5.org/canvas.html
if (e.pageX || e.pageY) {
x = e.pageX;
y = e.pageY;
} else {
x = e.clientX + document.body.scrollLeft +
document.documentElement.scrollLeft;
y = e.clientY + document.body.scrollTop +
document.documentElement.scrollTop;
}
// make coordinates relative to canvas
x -= canvas.offsetLeft;
y -= canvas.offsetTop;
return [x,y];
}
图 5-19 用不同浏览器的截图展示了这个例子的渲染。
图 5-19 。通过带有视频的交互式画布呈现幸运 cookies 示例
我们可以进一步改进这个例子,当鼠标悬停在盒子上时,将鼠标指针显示为抓手。为此,我们在画布上为mousemove
事件注册了一个回调,调用清单 5-19 中的函数,该函数在框内改变指针。
清单 5-19 。函数来改变鼠标光标在白盒顶部的位置
function procMove(e) {
var pos = canvasPosition(e);
var x = pos[0], y = pos[1];
if (x > (w/16) && x < (w*15/16) && y > (h/4) && y < (h*3/4)) {
document.body.style.cursor = "pointer";
} else {
document.body.style.cursor = "default";
}
}
您必须重用前面的canvasPosition()
函数来获取光标位置,然后在将它设置为“pointer”之前决定光标是否在框内
注意注意不同浏览器的字体呈现方式不同,但除此之外,它们都支持相同的功能。
摘要
在本章中,我们利用了 canvas 的一些功能来处理视频图像。
我们首先了解到,drawImage()
函数允许我们将图像从元素中取出,并作为像素数据放入画布中。然后,我们确定了处理画布中的视频帧的最有效方式,并发现“草稿画布”是一个有用的准备空间,用于处理需要作为模式操作一次并重复使用多次的视频帧。
我们认为getImageData()
和putImageData()
函数是操纵视频帧数据的强大助手。
然后,我们利用像素操作功能,如改变某些像素的透明度以实现蓝屏效果,缩放像素切片以实现 3D 效果,或计算视频帧的平均颜色以创建周围环境。我们还利用了createPattern()
函数在给定的矩形上复制一个视频帧。
然后我们转向画布的合成功能,将几个独立的功能放在一起。我们使用渐变从视频渐变到环境背景、剪辑路径和文本作为模板,从视频中剪切出某些区域。
有了画布转换功能,我们终于能够创建一个跨浏览器工作的视频反射。我们还用它来旋转视频帧,从而让它们在画布上盘旋。
我们通过将用户在画布上的点击与视频活动联系起来,总结了我们对画布的看法。因为画布上没有可寻址的对象,只有可寻址的像素位置,所以它不像 SVG 那样适合捕捉对象上的事件。
在下一章,我们将深入音频 API。我们在那里见。
六、通过网络音频 API 操纵音频
说到网络,音频是…“嗯”…它就在那里。这并不是贬低音频,但是,在许多方面,音频被视为一种事后的想法或一种烦恼。然而,它的重要性不能低估。从作为用户反馈的点击声等简单效果到描述产品或事件的画外音,音频是一种主要的沟通媒介,正如一位作者喜欢说的那样,“达成交易”
音频的关键在于,当它被数字化时,它可以被操纵。要做到这一点,我们需要停止将音频视为声音,并看到它的真实面目:可以被操纵的数据。这就把我们带到了本章的主题:如何在网络浏览器中操作声音数据。
Web Audio API(应用编程接口)补充了我们刚刚了解的处理视频数据的功能。这使得开发复杂的基于 web 的游戏或音频制作应用成为可能,在这些应用中,可以用 JavaScript 动态地创建和修改音频。它还支持音频数据的可视化和数据分析,例如,确定节拍或识别正在演奏的乐器,或者您听到的声音是女性还是男性。
Web 音频 API ( http://webaudio.github.io/web-audio-api/
)是 W3C 音频工作组正在开发的规范。这个规范已经在除了 IE 之外的所有主流桌面浏览器中实现。微软已经将它添加到它的开发路线图中,所以我们可以假设它会得到普遍支持。Safari 目前在实现中使用了一个webkit
前缀。Mozilla 曾经实现了一个更简单的音频处理规范,称为“音频数据 API ”,但后来也用 Web 音频 API 规范取代了它。
注 W3C 音频工作组也开发了一个 Web MIDI API 规范(
www.w3.org/TR/webmidi/
),但目前只提供给 Google Chrome 作为试用实现,所以我们暂时不解释这个 API。
在我们开始之前,回顾一下数字音频的基础知识是很有用的。
位深度和采样率
我们传统上将声音想象成正弦波——波越靠近,频率越高,因此声音也越高。至于波的高度,那叫做信号的振幅,波越高,声音越大。这些波,例如图 6-1 中的所示的,被称为波形。横线是时间,如果信号没有离开横线,那就是沉默。
图 6-1 。Adobe Audition CC 2014 的典型波形
对于任何要数字化的声音,比如 Fireworks 或 Photoshop 中的彩色图像,都需要对波形进行采样。样本只不过是以固定间隔采样的波形的快照。以一张音频 CD 为例,以每秒 44100 次的频率采样,传统上认定为 44.1kHz,在快照时刻采样的值是代表当时音量的数字。每秒钟采样波形的频率称为采样率。采样率越高,原始模拟声音的数字表现就越准确。当然,这样做的缺点是采样率越高,文件越大。
Bitdepth 是样本值的分辨率。8 位的位深度意味着快照表示为范围从–128 到 127 的数字(即,该值适合 8 位)。16 位的位深度意味着该数字在–32,768 到 32,767 之间。如果你计算一下,你会发现一个 8 位快照每个样本有 256 个潜在值,而它的 16 位副本每个样本只有 65,000 个潜在值。样本潜在值的数量越多,数字文件可以代表的动态范围就越大。
立体声信号 每只耳朵都有一个波形。这些波形中的每一个都被单独数字化成一系列样本。它们通常被存储为一系列的对,这些对被分开以回放到它们各自的通道中。
当这些数字按照采样的顺序和频率播放时,它们会重现声音的波形。显然,更大的位深度和更高的采样速率意味着回放波形的精度更高,对波形拍摄的快照越多,波形的表现就越准确。这解释了为什么一张专辑中的歌曲有如此大的文件大小。它们以尽可能高的位深度被采样。
最常用的三种采样率是 11.025kHz、22.05kHz 和 44.1kHz。如果将采样率从 44.1kHz 降低到 22.05kHz,文件大小将减少 50%。如果速率降低到 11.025kHz,您会获得更显著的降低,另一个 50%。问题是降低采样速率会降低音频质量。以 11.025 千赫的频率听贝多芬的第九交响曲会让音乐听起来像是从锡罐里放出来的。
作为一名网页设计者或开发者,你的主要目标是以最小的文件大小获得最好的音质。尽管许多开发人员会告诉你 16 位、44.1kHz 立体声是最佳选择,但你会很快意识到这不一定是真的。例如,16 位、44.1kHz 的立体声鼠标点击声或持续时间不到几秒钟的声音——如物体滑过屏幕时发出的嗖嗖声——都是对带宽的浪费。持续时间如此之短,声音中表现的频率如此有限,以至于如果你点击的是 8 位、22.05kHz 的单声道声音,普通用户不会意识到这一点。他们听到咔哒声后继续前进。音乐文件也是如此。普通用户最有可能通过购买电脑时附赠的廉价扬声器收听。在这种情况下,一个 16 位、22.05kHz 的音轨听起来会像它的 CD 质量丰富的表亲一样好。
HTML5 音频格式
在第一章中,我们已经讨论了用于 HTML 5 的三种音频格式:MP3、WAV 和 OGG Vorbis。这些都是编码音频格式(即,音频波形的原始样本被压缩以占用更少的空间,并能够在互联网上更快地传输)。所有这些格式都使用感知编码,这意味着它们从音频流中丢弃了人类通常无法感知的所有信息。当信息以这种方式被丢弃时,文件大小会相应减小。用于编码的信息包括你的狗能听到但你听不到的声音频率。简而言之,你只能听到人类能够感知的声音(这也解释了为什么动物不太喜欢 iPods)。
所有感知编码器允许你选择多少音频是不重要的。大多数编码器使用不超过 16 Kbps 的速度来创建语音记录,从而产生高质量的文件。例如,当你创作一首 MP3 时,你需要注意带宽。格式是好的,但是如果带宽没有针对其预期用途进行优化,您的结果将是不可接受的,这就是为什么创建 MP3 文件的应用要求您设置带宽和采样率。
在这一章中,我们将处理原始音频样本,并对其进行处理以获得专业的音频效果。浏览器负责为我们解码压缩的音频文件。
泛泛而谈就到此为止;让我们从实际出发,通过使用 Web 音频 API 来处理音频数据核心的 1 和 0。我们从过滤图和AudioContext
开始。
过滤图和音频上下文
Web Audio API 规范基于构建连接的AudioNode
对象的图形来定义整体音频渲染的思想。这非常类似于作为许多媒体框架基础的过滤图思想,包括 DirectShow、GStreamer 以及 JACK 音频连接工具包。
一个滤波器图背后的思想是,通过以特定方式修改输入数据的一系列滤波器(声音修改器)发送输入信号,将一个或多个输入信号(在我们的例子中:声音信号)连接到目的渲染器(声音输出)。
术语音频滤波器 可以指改变音频信号的音色、谐波含量、音高或波形的任何东西。该规格包括用于各种音频用途的滤波器,包括:
- 空间化的音频在 3D 空间中移动声音。
- 模拟声学空间的卷积引擎。
- 实时频率分析,以确定声音的组成。
- 提取特定频率区域的频率滤波器。
- 样本精确的预定声音回放。
Web Audio API 中的过滤图包含在一个AudioContext
中,由连接的AudioNode
对象组成,如图图 6-2 所示。
图 6-2 。网络音频 API 中过滤图的概念
正如您所看到的,存在没有传入连接的 AudioNode 对象—这些被称为源节点。它们也只能连接到一个音频节点。示例包括麦克风输入、媒体元素、远程麦克风输入(通过 WebRTC 连接时)、存储在内存缓冲区中的纯音频样本或振荡器等人工声源。
注****WebRTC(Web Real-Time Communication)是万维网联盟(W3C)起草的 API 定义,它支持浏览器到浏览器的应用进行语音通话、视频聊天和 P2P 文件共享,而无需内部或外部插件。这是一个很大的话题,超出了本书的范围。
没有传出连接的对象称为目的节点,它们只有一个传入连接。例如音频输出设备(扬声器)和远程输出设备(通过 WebRTC 连接时)。
中间的其他AudioNode
对象可能有多个输入连接和/或多个输出连接,是中间处理节点。
开发者不用担心两个AudioNode
对象连接时的低级流格式细节;正确的事情就会发生。例如,如果单声道音频滤波器连接了立体声输入,它将只接收左右声道的混音。
首先,让我们创建一个网络音频应用的“Hello World”。为了跟随示例,在http://html5videoguide.net/
中提供了完整的示例。清单 6-1 显示了一个简单的例子,一个振荡器源连接到默认的扬声器目的节点。你会听到频率为 1 千赫的声波。一句警告的话:我们将处理音频样本和文件,你可能要确保你的电脑音量降低。
清单 6-1 。振荡器源节点和声音输出目的节点的简单过滤图
// create web audio api context
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
// create Oscillator node
var oscillator = audioCtx.createOscillator();
oscillator.connect(audioCtx.destination);
oscillator.type = ’square’;
oscillator.frequency.value = 1000; // value in hertz
oscillator.start(0);
注意如果你想听到不同的音调,只需改变
oscillator.frequency.value
中的数字。如果你不添加频率值,默认是 440 赫兹,对于音乐倾向,是一个高于中音 c 的 A。为什么是这个值?如果你曾经去过交响乐团,听到乐队成员调整他们的乐器,那种音调已经成为音乐会音高的标准。
oscillator.start()
函数有一个可选参数来描述声音应该在什么时间开始播放。不幸的是,在 Safari 中它不是可选的。所以一定要加上 0。
图 6-3 显示了我们创建的过滤器图。
图 6-3 。网络音频 API 中的简单过滤图示例
让我们深入研究一下这个例子刚刚介绍的不同结构。
音频上下文接口
AudioContext
提供了创建AudioNode
对象并将其相互连接的环境。所有类型的AudioNode
对象都在下面代码所示的AudioContext
中定义。有许多AudioNode
对象,我们将一步一步地接近这个对象,并在本章的每一步解释我们需要的一些东西。
[Constructor]
interface AudioContext : EventTarget {
readonly attribute AudioDestinationNode destination;
readonly attribute float sampleRate;
readonly attribute double currentTime;
Promise suspend ();
Promise resume ();
Promise close ();
readonly attribute AudioContextState state;
attribute EventHandler onstatechange;
OscillatorNode createOscillator ();
...
};
interface AudioDestinationNode : AudioNode {
readonly attribute unsigned long maxChannelCount;
};
enum AudioContextState {
"suspended",
"running",
"closed"
};
每个AudioContext
包含一个只读AudioDestinationNode
。目的地通常是计算机连接的音频输出设备,如扬声器或耳机。脚本会将所有用户想要听到的音频连接到这个节点,作为AudioContext
的过滤图中的“终端”节点。你可以看到我们已经通过使用AudioContext
对象并在其上调用createOscillator()
创建了振荡器,并设置了振荡器的一些参数。然后我们通过调用振荡器对象上的connect()
函数将其连接到扬声器/耳机输出的目的地。
AudioContext
的sampleRate
在AudioContext
的生命周期内是固定的,并设置上下文中所有AudioNodes
的采样率。因此,在AudioContext
中不可能进行采样率转换。默认情况下,sampleRate
为 44,100Hz。
AudioContext
的currentTime
是以秒为单位的时间,表示AudioContext
的年龄(即,当上下文被创建时,它从零开始,并且实时增加)。所有预定时间都是相对于它的。重要的是要记住AudioContext
中的所有事件都是针对这个时钟运行的,它在几分之一秒内进行。
suspend()
、resume()
和close()
呼叫将影响currentTime
并暂停、恢复或停止其增加。他们也影响着AudioContext
是否能控制音频硬件。调用close()
后,AudioContext
不可用于创建新节点。AudioContextState
表示AudioContext
所处的状态:暂停、运行或关闭。
注意浏览器目前不支持
suspend()
、resume()
、close()
功能和状态属性。
清单 6-2 显示了一个AudioContext
的几个参数,结果显示在图 6-4 中。
清单 6-2 。音频上下文的参数
<div id="display"></div>
<script type="text/javascript">
var display = document.getElementById("display");
var context = new (window.AudioContext || window.webkitAudioContext)();
display.innerHTML = context.sampleRate + " sampling rate<br/>";
display.innerHTML += context.destination.numberOfChannels
+ " output channels<br/>";
display.innerHTML += context.currentTime + " currentTime<br/>";
</script>
图 6-4 。Chrome 中默认的 AudioContext 的参数
在通过AudioDestinationNode
播放声音之前,不知道输出通道的数量。
让我们再看看那个振荡器。它属于类型OscillatorNode
,包含一些我们可以操作的属性。
interface OscillatorNode : AudioNode {
attribute OscillatorType type;
readonly attribute AudioParam frequency;
readonly attribute AudioParam detune;
void start (optional double when = 0);
void stop (optional double when = 0);
void setPeriodicWave (PeriodicWave periodicWave);
attribute EventHandler onended;
};
enum OscillatorType {
"sine",
"square",
"sawtooth",
"triangle",
"custom"
};
第一个属性是OscillatorType
,我们在例子中将其设置为“square”。您可以在示例中改变它,并会注意到音调的音色如何变化,而其频率保持不变。
频率是一个AudioParam
对象,我们马上就会看到。它有一个可以设置的值,在我们的示例中,我们将其设置为 1,000Hz。
OscillatorNode
还有一个detune
属性,它将频率偏移给定的百分比量。其默认值为 0。去谐有助于使音符听起来更自然。
OscillatorNode
上的start()
和stop()
方法参照AudioContext
的currentTime
确定振荡器的开始和结束时间。请注意,您只能调用start()
和stop()
一次,因为它们定义了声音存在的范围。但是,您可以将振荡器与AudioDestinationNode
(或滤波器图中的下一个AudioNode
)连接或断开,以暂停/取消暂停声音。
setPeriodicWave()
功能允许设置自定义振荡器波形。使用AudioContext
的createPeriodicWave()
功能,使用傅立叶系数阵列创建自定义波形,作为周期波形的分波。除非你在写合成器,否则你可能不需要理解这个。
AudioParam 接口
我们刚刚用于OscillatorNode
的frequency
和detune
属性的AudioParam
对象类型实际上相当重要,所以让我们试着更好地理解它。它是AudioNode
在滤波器图中进行的任何音频处理的核心,因为它保存了控制AudioNodes
关键方面的参数。在我们的例子中,它是振荡器运行的频率。我们可以通过value
参数随时改变频率。这意味着一个事件被安排在下一个可能的时刻改变振荡器的频率。
由于浏览器可能会非常繁忙,所以无法预测这一事件何时会发生。在我们的例子中,这可能没问题,但如果你是一名音乐家,你会希望你的计时非常准确。因此,每个AudioParam
维护一个按时间顺序排列的变更事件列表。计划更改的时间在AudioContext’s currentTime
属性的时间坐标系中。这些事件要么立即启动更改,要么启动/结束更改。
以下是AudioParam
界面的组件:
interface AudioParam {
attribute float value;
readonly attribute float defaultValue;
void setValueAtTime (float value, double startTime);
void linearRampToValueAtTime (float value, double endTime);
void exponentialRampToValueAtTime (float value, double endTime);
void setTargetAtTime (float target, double startTime, float timeConstant);
void setValueCurveAtTime (Float32Array values, double startTime,
double duration);
void cancelScheduledValues (double startTime);
};
事件列表由AudioContext
在内部维护。以下方法可以通过向列表中添加新事件来更改事件列表。事件的类型由方法定义。这些方法被称为自动化方法。
setValueAtTime()
告诉AudioNode
在给定的startTime
处将其AudioParam
改为值。linearRampToValueAtTime()
告知AudioNode
通过给定的endTime
将其AudioParam
值提升至值。这意味着它要么从“现在”开始,要么从AudioNode
事件列表中的前一个事件开始。exponentialRampToValueAtTime()
告诉AudioNode
使用指数连续变化从先前预定的参数值到给定的value
通过给定的endTime
增加其AudioParam
值。由于人类感知声音的方式,表示滤波器频率和回放速率的参数最好以指数方式变化。setTargetAtTime()
告诉AudioNode
以给定的startTime
开始以给定timeConstant
的速率指数逼近目标value
。在其他用途中,这对于实现 ADSR(起音-衰减-延音-释放)包络的“衰减”和“释放”部分很有用。参数值不会在给定时间立即变为目标值,而是逐渐变为目标值。timeConstant
越大,过渡越慢。setValueCurveAtTime()
告诉AudioNode
按照从给定startTime
开始的任意参数values
的数组为给定duration
修改其值。值的数量将被缩放以适合所需的duration
。cancelScheduledValues()
告诉AudioNode
取消所有时间大于或等于startTime
的预定参数变更。
预定事件对于包络、音量渐变、lfo(低频振荡)、滤波器扫描或颗粒窗口等任务非常有用。我们不打算解释这些,但是专业的音乐人会知道它们是什么。重要的是,您要理解自动化方法提供了一种在给定时间实例中将参数值从一个值更改为另一个值的机制,并且这种更改可以遵循不同的曲线。这样,可以在任何AudioParam
上设定任意基于时间线的自动化曲线。我们将在一个例子中看到这意味着什么。
使用时间线,图 6-5 显示了一个自动计划,用于使用前面介绍的所有方法改变振荡器的频率。黑色的是setValueAtTime
调用,每个调用的value
是一个黑色的叉号,它们的startTime
在currentTime
时间线上。exponentialRampToValueAtTime
和linearRampToValueAtTime
调用在endTime
(时间线上的灰色线)处有一个目标value
(灰色十字)。setTargetAtTime
调用有一个startTime
(时间线上的灰色线)和一个目标value
(灰色十字)。setValueCurveAtTime
调用有一个startTime
、一个duration
和在此期间它经历的多个values
。所有这些结合在一起就产生了哔哔声和你在测试代码时听到的变化。
图 6-5 。振荡器频率的 AudioParam 自动化
清单 6-3 展示了我们如何用这种自动化来改编清单 6-1 。
清单 6-3 。振荡器的频率自动化
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var oscillator = audioCtx.createOscillator();
var freqArray = new Float32Array(5);
freqArray[0] = 4000;
freqArray[1] = 3000;
freqArray[2] = 1500;
freqArray[3] = 3000;
freqArray[4] = 1500;
oscillator.type = ’square’;
oscillator.frequency.value = 100; // value in hertz
oscillator.connect(audioCtx.destination);
oscillator.start(0);
oscillator.frequency.cancelScheduledValues(audioCtx.currentTime);
oscillator.frequency.setValueAtTime(500, audioCtx.currentTime + 1);
oscillator.frequency.exponentialRampToValueAtTime(4000,
audioCtx.currentTime + 4);
oscillator.frequency.setValueAtTime(3000, audioCtx.currentTime + 5);
oscillator.frequency.linearRampToValueAtTime(1000,
audioCtx.currentTime + 8);
oscillator.frequency.setTargetAtTime(4000, audioCtx.currentTime + 10, 1);
oscillator.frequency.setValueAtTime(1000, audioCtx.currentTime + 12);
oscillator.frequency.setValueCurveAtTime(freqArray,
audioCtx.currentTime + 14, 4);
AudioNodes
实际上可以对每个单独的音频样本或 128 个样本的块进行值计算。当需要对块的每个样本单独进行数值计算时,AudioParam
将指定它是一个 a-rate 参数。它将指定它是一个 k-rate 参数,此时只计算块的第一个样本,结果值用于整个块。
振荡器的frequency
和detune
参数都是 a 速率参数,因为每个单独的音频样本都可能需要调整频率和失谐。
a-rate AudioParam
获取音频信号的每个样本帧的当前音频参数值。
一个 k-rate AudioParam
对处理的整个块(即 128 个样本帧)使用相同的初始音频参数值。
音频节点接口
前面我们已经熟悉了OscillatorNode
,它是AudioNode
的一种,是过滤图的构建模块。一个OscillatorNode
是一个源节点。我们还熟悉了过滤图中的一种目的节点类型:??。是时候我们深入了解一下AudioNode
接口本身了,因为这是OscillatorNode
的connect()
功能的来源。
interface AudioNode : EventTarget {
void connect (AudioNode destination, optional unsigned long output = 0,
optional unsigned long input = 0);
void connect (AudioParam destination, optional unsigned long output = 0 );
void disconnect (optional unsigned long output = 0);
readonly attribute AudioContext context;
readonly attribute unsigned long numberOfInputs;
readonly attribute unsigned long numberOfOutputs;
attribute unsigned long channelCount;
attribute ChannelCountMode channelCountMode;
attribute ChannelInterpretation channelInterpretation;
};
一个AudioNode
只能属于一个AudioContext
,存储在context
属性中。
AudioNode
中的第一个connect()
方法将它连接到另一个AudioNode
。在一个特定节点的给定输出和另一个节点的给定输入之间只能有一个连接。output
参数指定要连接的输出索引,类似地input
参数指定要连接到目的地AudioNode
的哪个输入索引。
AudioNode
的numberOfInputs
属性提供了输入到AudioNode
的输入数量,而numberOfOutputs
提供了从AudioNode
输出的数量。源节点有 0 个输入,目的节点有 0 个输出。
一个AudioNode
可能有比输入更多的输出;因此支持扇出。它的输入可能多于输出,这支持扇入。将一个AudioNode
连接到另一个AudioNode
并返回是可能的,从而形成一个循环。这只有在循环中至少有一个DelayNode
时才被允许,否则你会得到一个NotSupportedError
异常。
非源和非目标AudioNode
的每个输入和输出都有一个或多个通道。输入、输出及其通道的确切数量取决于AudioNode
的类型。
channelCount
属性包含AudioNode
固有处理的通道数量。默认情况下,它是 2,但是可能会被这个属性的一个显式的新值覆盖,或者通过channelCountMode
属性覆盖。
enum ChannelCountMode { "max", "clamped-max", "explicit" };
这些值具有以下含义:
- 当
channelCountMode
为“max”时,AudioNode
处理的通道数是所有输入和输出连接的最大通道数,channelCount
被忽略。 - 当
channelCountMode
为“clamped-max”时,AudioNode
处理的通道数是所有输入和输出连接的最大通道数,但也是channelCount
的最大值。 - 当
channelCountMode
为“显式”时,AudioNode
处理的通道数量由channelCount
决定。
对于每个输入,AudioNode
混合(通常是上混合)到该节点的所有连接。当输入的通道需要向下或向上混合时,channelInterpretation
属性决定如何处理这种向下或向上混合。
enum ChannelInterpretation { "speakers", "discrete" };
当channelInterpretation
为“离散”时,上混是通过填充通道直到它们用完,然后将剩余通道归零,下混是通过填充尽可能多的通道,然后丢弃剩余通道。
如果channelInterpretation
设置为“speaker”,则为特定的声道布局定义上混和下混:
- 1 通道:单声道(通道 0)
- 2 个通道:左通道(通道 0),右通道(通道 1)
- 4 声道:左声道(通道 0)、右声道(通道 1)、环绕左声道(通道 2)、环绕右声道(通道 3)
- 5.1 声道:左声道(ch 0)、右声道(ch 1)、中声道(ch 2)、低音炮(ch 3)、左环绕声道(ch 4)、右环绕声道(ch 5)
向上混合的工作方式如下:
- 单声道:复制到左右声道(2 和 4 声道),复制到中央(5.1 声道)
- 立体声:复制到左右声道(适用于 4 声道和 5.1 声道)
- 4 声道:复制到左侧和右侧,环绕左侧和环绕右侧(适用于 5.1)
每隔一个频道保持在 0。
缩混的工作原理如下:
-
单声道缩混:
- 2 -> 1: 0.5 *(左+右)
- 4 -> 1: 0.25 *(左+右+环绕左+环绕右)
- 5.1 -> 1: 0.7071 *(左+右)+居中
-
- 0.5 *(左环绕+右环绕)
-
立体声缩混:
-
4 -> 2: left = 0.5 * (left + surround left)
左= 0.5 *(右+环绕右)
-
5.1 -> 2: left = L + 0.7071 * (center + surround left)
右= R + 0.7071 *(中央+环绕右侧)
-
-
四声道缩混:
-
5.1 -> 4: left = left + 0.7071 * center
右=右+ 0.7071 *中心
环绕左=环绕左
右环绕=右环绕
-
图 6-6 描述了一个AudioNode
的假设输入和输出场景,其中每个场景都有不同的通道集。如果通道数不在 1、2、4 和/或 6 中,则使用“离散”解释。
图 6-6 。音频节点的通道和输入/输出
最后,AudioNode
对象上的第二个connect()
方法将一个AudioParam
连接到一个AudioNode
。这意味着参数值由音频信号控制。
可以将一个AudioNode
输出连接到一个以上的AudioParam
,并多次调用connect()
,从而支持扇出并通过一个音频信号控制多个AudioParam
设置。也可以通过对connect();
的多次调用将多个AudioNode
输出连接到单个AudioParam
,从而支持扇入并控制具有多个音频输入的单个AudioParam
。
一个特定节点的给定输出和一个特定的AudioParam
之间只能有一个连接。同一AudioNode
和同一AudioParam
之间的多个连接被忽略。
一个AudioParam
将从连接到它的任何AudioNode
输出获取渲染的音频数据,并通过缩混将其转换为单声道(如果它还不是单声道)。接下来,它会将它与任何其他此类输出以及内在参数值(没有任何音频连接时,AudioParam
通常会具有的值)混合在一起,包括为该参数安排的任何时间线更改。
我们将通过一个操纵GainNode
增益设置的振荡器的例子来演示这一功能,即所谓的 LFO。增益仅仅意味着增加信号的功率,这导致其音量增加。GainNode
的增益设置被置于频率固定的振荡器和目的节点之间,从而使固定音调以振荡增益呈现(见清单 6-4 )。
清单 6-4 。一个振荡器的增益由另一个振荡器控制
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var oscillator = audioCtx.createOscillator();
// second oscillator that will be used as an LFO
var lfo = audioCtx.createOscillator();
lfo.type = ’sine’;
lfo.frequency.value = 2.0; // 2Hz: low-frequency oscillation
// create a gain whose gain AudioParam will be controlled by the LFO
var gain = audioCtx.createGain();
lfo.connect(gain.gain);
// set up the filter graph and start the nodes
oscillator.connect(gain);
gain.connect(audioCtx.destination);
oscillator.start(0);
lfo.start(0);
当运行清单 6-3 时,您将听到频率为 440Hz(振荡器的默认频率)的音调以每秒两次的频率在增益 0 和 1 之间波动。图 6-7 解释了过滤图的设置。请特别注意,lfo OscillatorNode
连接到GainNode
的增益参数,而不是增益节点本身。
图 6-7 。音频节点的通道和输入/输出
我们只是使用了AudioContext
的另一个函数来创建一个GainNode
。
[Constructor] interface AudioContext : EventTarget {
...
GainNode createGain ();
...
}
为了完整起见,以下是GainNode
的定义:
interface GainNode : AudioNode {
readonly attribute AudioParam gain;
};
gain
参数表示要应用的增益量。其默认值为 1(增益不变)。标称值minValue
为 0,但对于反相可能为负。简而言之,相位反转就是“翻转信号”——想想我们一开始用来解释音频信号的正弦波——反转它们的相位意味着在时间轴上镜像它们的值。名义上的maxValue
是 1,但是允许更高的值。这个参数是一个 a-rate。
一个GainNode
接受一个输入并创建一个输出,ChannelCountMode
是“max”(即,它处理给定的尽可能多的通道),而ChannelInterpretation
是“speakers”(即,对输出执行上混合或下混合)。
读取和生成音频数据
到目前为止,我们已经通过振荡器创建了音频数据。然而,一般来说,您会想要读取一个音频文件,然后获取音频数据并对其进行操作。
AudioContext
提供了这样的功能。
[Constructor] interface AudioContext : EventTarget {
...
Promise<AudioBuffer> decodeAudioData (ArrayBuffer audioData,
optional DecodeSuccessCallback successCallback,
optional DecodeErrorCallback errorCallback);
AudioBufferSourceNode createBufferSource();
...
}
callback DecodeErrorCallback = void (DOMException error);
callback DecodeSuccessCallback = void (AudioBuffer decodedData);
decodeAudioData()
函数异步解码ArrayBuffer
中包含的音频文件数据。要使用它,我们首先必须将音频文件提取到一个ArrayBuffer
中。然后我们可以将它解码成一个AudioBuffer
,并将那个AudioBuffer
交给一个AudioBufferSourceNode
。现在它在AudioNode
中,并且可以通过过滤图连接(例如,通过目的节点回放)。
XHR ( HTMLHttpRequest
)接口用于从服务器获取数据。我们将使用它将文件数据放入一个ArrayBuffer
中。我们假设您熟悉 XHR,因为它不是特定于媒体的界面。
在清单 6-5 的中,我们使用 XHR 检索文件“transition.wav ”,然后通过调用AudioContext
的decodeAudioData()
函数将接收到的数据解码成一个AudioBufferSourceNode
。
注意感谢 CadereSounds 在 freesound 的知识共享许可下提供“transition.wav”样本(见
www.freesound.org/people/CadereSounds/sounds/267125/
)。
清单 6-5 。使用 XHR 获取媒体资源
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var source = audioCtx.createBufferSource();
var request = new XMLHttpRequest();
var url = ’audio/transition.wav’;
function requestData(url) {
request.open(’GET’, url, true);
request.responseType = ’arraybuffer’;
request.send();
}
function receivedData() {
if ((request.status === 200 || request.status === 206)
&& request.readyState === 4) {
var audioData = request.response;
audioCtx.decodeAudioData(audioData,
function(buffer) {
source.buffer = buffer;
source.connect(audioCtx.destination);
source.loop = true;
source.start(0);
},
function(error) {
"Error with decoding audio data" + error.err
}
);
}
}
request.addEventListener(’load’, receivedData, false);
requestData(url);
首先我们定义一个函数,它对文件进行 XHR 请求,然后在网络检索之后调用receivedData()
函数。如果检索成功,我们将得到的ArrayBuffer
交给decodeAudioData()
。
注意你必须把这个上传到网络服务器,因为 XHR 认为文件:URL 是不可信的。你也可以使用更现代的 API 来代替 XHR,它没有同样的问题。
我们按顺序来看一下涉及的对象。
首先,XHR 从服务器获取音频文件的字节,并将它们放入一个ArrayBuffer
中。浏览器可以解码任何格式的数据,?? 元素也可以解码。 decodeAudioData()
功能将音频数据解码成线性 PCM。如果成功,它将被重采样到AudioContext
的采样率,并存储在一个AudioBuffer
对象中。
音频缓冲接口
interface AudioBuffer {
readonly attribute float sampleRate;
readonly attribute long length;
readonly attribute double duration;
readonly attribute long numberOfChannels;
Float32Array getChannelData (unsigned long channel);
void copyFromChannel (Float32Array destination, long channelNumber,
optional unsigned long startInChannel = 0);
void copyToChannel (Float32Array source, long channelNumber,
optional unsigned long startInChannel = 0);
};
该接口表示驻留在存储器中的音频资产(例如,用于单镜头声音和其他短音频剪辑)。其格式为非交错 IEEE 32 位线性 PCM,标称范围为-1 至+1。它可以包含一个或多个通道,并可由一个或多个AudioContext
对象使用。
您通常使用AudioBuffer
来播放短声音——对于较长的声音,例如音乐配乐,您应该使用带有音频元素和MediaElementAudioSourceNode
的流。
sampleRate
属性包含音频资产的采样率。
length
属性包含样本帧中音频资源的长度。
duration
属性包含以秒为单位的音频资产的持续时间。
numberOfChannels
属性包含音频资产的离散通道的数量。
getChannelData()
方法返回特定通道 PCM 音频数据的 float 32 数组。
copyFromChannel()
方法将样本从AudioBuffer
的指定通道复制到目的 Float32Array。可在startInChannel
参数中提供从通道复制数据的可选偏移量。
copyToChannel()
方法将样本从 source Float32Array 复制到AudioBuffer
的指定通道。可在startInChannel
参数中提供从通道复制数据的可选偏移量。
可以将AudioBuffer
添加到AudioBufferSourceNode
中,以便音频资产进入过滤网络。
您可以使用AudioContext
的createBuffer()
方法直接创建一个AudioBuffer
。
[Constructor]
interface AudioContext : EventTarget {
...
AudioBuffer createBuffer (unsigned long numberOfChannels,
unsigned long length,
float sampleRate);
...
};
它将用给定长度(以采样帧为单位)、采样率和通道数的样本填充,并且只包含无声段。然而,最常见的是,AudioBuffer
用于存储解码样本。
AudioBufferSourceNode 接口
interface AudioBufferSourceNode : AudioNode {
attribute AudioBuffer? buffer;
readonly attribute AudioParam playbackRate;
readonly attribute AudioParam detune;
attribute boolean loop;
attribute double loopStart;
attribute double loopEnd;
void start (optional double when = 0, optional double offset = 0,
optional double duration);
void stop (optional double when = 0);
attribute EventHandler onended;
};
一个AudioBufferSourceNode
表示一个音频源节点,在一个AudioBuffer
中有一个存储器内音频资产。因此,它有 0 个输入和 1 个输出。这对于播放短音频资源很有用。输出的通道数总是等于分配给buffer
属性的AudioBuffer
的通道数,或者如果buffer
为空,则为一个无声通道。
buffer
属性包含音频资产。
playbackRate
属性包含渲染音频资源的速度。其默认值为 1。这个参数是 k-rate。
detune
属性调节音频资源渲染的速度。其默认值为 0。其标称范围为[-1200;1,200].这个参数是 k-rate。
playbackRate
和detune
两者一起用于确定随时间 t 变化的computedPlaybackRate
值:
computedPlaybackRate(t) = playbackRate(t) * pow(2, detune(t) / 1200)
computedPlaybackRate
是该AudioBufferSourceNode
的AudioBuffer
必须播放的有效速度。默认情况下是 1。
loop
属性表示音频数据是否应该循环播放。默认值为 false。
loopStart
和loopEnd
属性以秒为单位提供了循环运行的时间间隔。默认情况下,它们从 0 到缓冲持续时间。
start()
方法用于安排声音回放的时间。当缓冲区的音频数据播放完毕时(如果 loop 属性为 false),或者当调用了stop()
方法并且到达了指定的时间时,播放将自动停止。对于一个给定的AudioBufferSourceNode
,不能多次发出start()
和stop()
。
由于AudioBufferSourceNode
是一个AudioNode
,它有一个connect()
方法参与过滤网络(例如,连接到音频目的地进行回放)。
MediaElementAudioSourceNode 接口
另一种可用于将音频数据放入过滤图的源节点是MediaElementAudioSourceNode
。
interface MediaElementAudioSourceNode : AudioNode {
};
AudioContext
提供了创建这样一个节点的功能。
[Constructor] interface AudioContext : EventTarget {
...
MediaElementAudioSourceNode createMediaElementSource(HTMLMediaElement
mediaElement);
...
}
总之,它们允许将来自
或元素的音频作为源节点引入。因此,MediaElementAudioSourceNode
有 0 个输入和 1 个输出。输出的通道数对应于HTMLMediaElement
引用的媒体的通道数,作为参数传递给createMediaElementSource()
,如果HTMLMediaElement
没有音频,则为一个无声通道。一旦连接,HTMLMediaElement
的音频不再直接播放,而是通过过滤图播放。
对于较长的媒体文件,MediaElementAudioSourceNode
应该优先于AudioBufferSourceNode
使用,因为MediaElementSourceNode
传输资源。清单 6-6 显示了一个例子。
清单 6-6 。将音频元素流式传输到音频上下文中
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementByTagName(’audio’)[0];
mediaElement.addEventListener(’play’, function() {
var source = audioCtx.createMediaElementSource(mediaElement);
source.connect(audioCtx.destination);
source.start(0);
});
我们必须等待 play 事件触发,以确保音频已经加载并被解码,这样AudioContext
就可以获得数据。清单 6-6 中音频元素的音频恰好回放一次。
MediaStreamAudioSourceNode 接口
可用于将音频数据放入过滤器图的最后一种源节点是MediaStreamAudioSourceNode
。
interface MediaStreamAudioSourceNode : AudioNode {
};
该接口表示来自MediaStream
的音频源,它基本上是一个现场音频输入源——麦克风。我们不会在本书中描述MediaStream
API——这超出了本书的范围。然而,一旦你有了这样一个MediaStream
对象,AudioContext
就提供了将MediaStream
的第一个AudioMediaStreamTrack
(音轨)转换成过滤图中的一个音频源节点的功能。
[Constructor] interface AudioContext : EventTarget {
...
MediaStreamAudioSourceNode createMediaStreamSource(MediaStream
mediaStream);
...
}
因此,MediaStreamAudioSourceNode
有 0 个输入和 1 个输出。输出的通道数对应于AudioMediaStreamTrack
的通道数,通过参数传递给createMediaStreamSource
(),如果MediaStream
没有音频,则为一个无声通道。
清单 6-7 显示了一个例子。
清单 6-7 。将音频流的音频提取到 AudioContext
navigator.getUserMedia = (navigator.getUserMedia ||
navigator.webkitGetUserMedia ||
navigator.mozGetUserMedia ||
navigator.msGetUserMedia);
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source;
onSuccess = function(stream) {
mediaElement.src = window.URL.createObjectURL(stream) || stream;
mediaElement.onloadedmetadata = function(e) {
mediaElement.play();
mediaElement.muted = ’true’;
};
source = audioCtx.createMediaStreamSource(stream);
source.connect(audioCtx.destination);
};
onError = function(err) {
console.log(’The following getUserMedia error occured: ’ + err);
};
navigator.getUserMedia ({ audio: true }, onSuccess, onError);
清单 6-6 中音频元素的音频通过过滤网络播放,尽管在音频元素上被静音。
注意还有一个类似的
MediaStreamAudioDestinationNode
,用于将过滤图的输出渲染到一个MediaStream
对象,为通过对等连接到另一个浏览器的音频流做准备。AudioContext
的createMediaStreamDestination()
函数创建了这样一个目的节点。然而,这目前只在 Firefox 中实现。
操纵音频数据
到目前为止,我们已经了解了如何通过四种不同的机制为我们的音频滤波器图创建音频数据:振荡器、音频缓冲区、音频文件和麦克风源。接下来让我们看看AudioContext
提供给 web 开发者的一组音频操作函数。这些是标准的音频操作功能,音频专业人员会很好地理解。
这些操作功能中的每一个都通过一个处理节点在过滤图中表示,并通过AudioContext
中的 create-function 创建:
[Constructor] interface AudioContext : EventTarget {
...
GainNode createGain ();
DelayNode createDelay(optional double maxDelayTime = 1.0);
BiquadFilterNode createBiquadFilter ();
WaveShaperNode createWaveShaper ();
StereoPannerNode createStereoPanner ();
ConvolverNode createConvolver ();
ChannelSplitterNode createChannelSplitter(optional unsigned long
numberOfOutputs = 6 );
ChannelMergerNode createChannelMerger(optional unsigned long
numberOfInputs = 6 );
DynamicsCompressorNode createDynamicsCompressor ();
...
}
增益节点接口
GainNode
表示音量的变化,用AudioContext
的createGain()
方法创建。
interface GainNode : AudioNode {
readonly attribute AudioParam gain;
};
它使输入数据在传播到输出之前获得给定的增益。一个GainNode
总是正好有一个输入和一个输出,两者都有相同数量的通道:
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “麦克斯” |
| 频道计数 | Two |
| 渠道解释 | “扬声器” |
gain
参数是一个无单位值,标称值介于 0 和 1 之间,其中 1 表示无增益变化。该参数为 a-rate,因此增益应用于每个样本帧,并乘以所有输入通道的每个相应样本。
增益可以随着时间而改变,并且使用去压缩算法来应用新的增益,以防止在所得到的音频中出现不美观的“咔哒声”。
清单 6-8 展示了一个通过滑动条操纵音频信号增益的例子。确保释放滑块,这样当您自己尝试时,滑块的值实际上会改变。过滤图由一个MediaElementSourceNode
、一个GainNode
和一个AudioDestinationNode
组成。
清单 6-8 。操纵音频信号的增益
<audio autoplay controls src="audio/Shivervein_Razorpalm.wav"></audio>
<input type="range" min="0" max="1" step="0.05" value="1"/>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
var gainNode = audioCtx.createGain();
source.connect(gainNode);
gainNode.connect(audioCtx.destination);
var slider = document.getElementsByTagName(’input’)[0];
slider.addEventListener(’change’, function() {
gainNode.gain.value = slider.value;
});
</script>
注意记得把这个例子上传到网络服务器,因为 XHR 认为文件的 URL 是不可信的。
延迟节点接口
DelayNode
将输入的音频信号延迟一定的秒数,并使用AudioContext
的createDelay()
方法创建。
interface DelayNode : AudioNode {
readonly attribute AudioParam dealyTime;
};
默认delayTime
为 0 秒(无延迟)。当延迟时间改变时,过渡是平滑的,没有明显的滴答声或毛刺。
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “麦克斯” |
| 频道计数 | Two |
| 渠道解释 | “扬声器” |
最小值为 0,最大值由AudioContext
方法createDelay
的maxDelayTime
参数决定。
该参数为 a-rate,因此延迟应用于每个样本帧,并乘以所有输入声道的每个相应样本。
一个DelayNode
通常用于创建一个滤波器节点循环(例如,结合一个GainNode
来创建一个重复的、衰减的回声)。当在一个周期中使用一个DelayNode
时,delayTime
属性的值被限制在最少 128 帧(一个块)。
清单 6-9 显示了一个衰减回声的例子。
清单 6-9 。通过增益和延迟滤波器衰减回声
<audio autoplay controls src="audio/Big%20Hit%201.wav"></audio>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
mediaElement.addEventListener(’play’, function() {
var source = audioCtx.createMediaElementSource(mediaElement);
var delay = audioCtx.createDelay();
delay.delayTime.value = 0.5;
var gain = audioCtx.createGain();
gain.gain.value = 0.8;
// play once
source.connect(audioCtx.destination);
// create decaying echo filter graph
source.connect(delay);
delay.connect(gain);
gain.connect(delay);
delay.connect(audioCtx.destination);
});
</script>
使用createMediaElementSource()
将音频重定向到滤波器图中。源声音直接连接到目的地进行正常回放,然后也馈入延迟和增益滤波器循环,衰减回声也连接到目的地。图 6-8 显示了创建的过滤器图形。
图 6-8 。衰减回声的滤波器图
注意感谢 robertmcdonald 在 freesound 上制作了《Big Hit 1.wav》的知识共享许可样本(见
www.freesound.org/people/robertmcdonald/sounds/139501/
)。
BiquadFilterNode 接口
BiquadFilterNode
代表低阶滤波器(详见http://en.wikipedia.org/wiki/Digital_biquad_filter
),由AudioContext
的createBiquadFilter()
方法创建。低阶滤波器是基本音调控制(低音、中音、高音)、图形均衡器和更高级滤波器的构件。
interface BiquadFilterNode : AudioNode {
attribute BiquadFilterType type;
readonly attribute AudioParam frequency;
readonly attribute AudioParam detune;
readonly attribute AudioParam Q;
readonly attribute AudioParam gain;
void getFrequencyResponse (Float32Array frequencyHz,
Float32Array magResponse,
Float32Array phaseResponse);
};
滤波器参数 s 可以随时间变化(例如,频率变化产生滤波器扫描)。
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “麦克斯” |
| 频道计数 | 输出中的数量与输入中的数量相同 |
| 渠道解释 | “扬声器” |
每个BiquadFilterNode
都可以配置为多种常见滤波器类型之一。
enum BiquadFilterType {
"lowpass",
"highpass",
"bandpass",
"lowshelf",
"highshelf",
"peaking",
"notch",
"allpass"
};
默认的滤镜类型是lowpass
( http://webaudio.github.io/web-audio-api/#the-biquadfilternode-interface
)。
frequency
参数的默认值为 350Hz,从 10Hz 开始,一直到奈奎斯特频率的一半(对于AudioContext
的默认 44.1kHz 采样速率为 22,050Hz)。它根据滤波器类型提供频率特性,例如,低通滤波器和高通滤波器的截止频率,或者带通滤波器的频带中心。
detune
参数提供了一个失谐频率的百分比值,使其更加自然。默认为 0。
Frequency
和detune
是速率参数,它们共同决定滤波器的计算频率:
computedFrequency(t) = frequency(t) * pow(2, detune(t) / 1200)
Q
参数是双二阶滤波器的品质因数,默认值为 1,标称范围为 0.0001 至 1,000(尽管 1 至 100 最为常见)。
gain
参数提供应用于双二阶滤波器的升压(dB ),默认值为 0,标称范围为-40 至 40(尽管 0 至 10 最为常见)。
getFrequencyResponse()
方法计算frequencyHz
频率数组中指定频率的频率响应,并返回magResponse
输出数组中的线性幅度响应值和phaseResponse
输出数组中以弧度表示的相位响应值。这对于可视化过滤器形状特别有用。
清单 6-10 显示了应用于音频源的不同滤波器类型的例子。
清单 6-10 。应用于音频源的不同双二阶滤波器类型
<audio autoplay controls src="audio/Shivervein_Razorpalm.wav"></audio>
<select class="type">
<option>lowpass</option>
<option>highpass</option>
<option>bandpass</option>
<option>lowshelf</option>
<option>highshelf</option>
<option>peaking</option>
<option>notch</option>
<option>allpass</option>
</select>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
var bass = audioCtx.createBiquadFilter();
// Set up the biquad filter node with a low-pass filter type
bass.type = "lowpass";
bass.frequency.value = 6000;
bass.Q.value = 1;
bass.gain.value = 10;
mediaElement.addEventListener(’play’, function() {
// create filter graph
source.connect(bass);
bass.connect(audioCtx.destination);
});
// Update the biquad filter type
var type = document.getElementsByClassName(’type’)[0];
type.addEventListener(’change’, function() {
bass.type = type.value;
});
</script>
输入音频文件连接到一个双二阶滤波器,频率值为 6,000Hz,品质因数为 1,增益为 10 dB。过滤器的类型可以通过下拉菜单在所有八种不同的过滤器之间进行更改。这样,您可以很好地了解这些滤波器对音频信号的影响。
在这个例子中使用getFrequencyResponse()
方法,我们可以可视化过滤器(参见http://webaudio-io2012.appspot.com/#34
)。清单 6-11 展示了如何绘制频率增益图。
清单 6-11 。绘制频率增益图
<canvas width="600" height="200"></canvas>
<canvas width="600" height="200" style="display: none;"></canvas>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var canvas = document.getElementsByTagName(’canvas’)[0];
var ctxt = canvas.getContext(’2d’);
var scratch = document.getElementsByTagName(’canvas’)[1];
var sctxt = scratch.getContext(’2d’);
var dbScale = 60;
var width = 512;
var height = 200;
var pixelsPerDb = (0.5 * height) / dbScale;
var nrOctaves = 10;
var nyquist = 0.5 * audioCtx.sampleRate;
function dbToY(db) {
var y = (0.5 * height) - pixelsPerDb * db;
return y;
}
function drawAxes() {
ctxt.textAlign = "center";
// Draw frequency scale (x-axis).
for (var octave = 0; octave <= nrOctaves; octave++) {
var x = octave * width / nrOctaves;
var f = nyquist * Math.pow(2.0, octave - nrOctaves);
var value = f.toFixed(0);
var unit = ’Hz’;
if (f > 1000) {
unit = ’KHz’;
value = (f/1000).toFixed(1);
}
ctxt.strokeStyle = "black";
ctxt.strokeText(value + unit, x, 20);
ctxt.beginPath();
ctxt.strokeStyle = "gray";
ctxt.lineWidth = 1;
ctxt.moveTo(x, 30);
ctxt.lineTo(x, height);
ctxt.stroke();
}
// Draw decibel scale (y-axis).
for (var db = -dbScale; db < dbScale - 10; db += 10) {
var y = dbToY(db);
ctxt.strokeStyle = "black";
ctxt.strokeText(db.toFixed(0) + "dB", width + 40, y);
ctxt.beginPath();
ctxt.strokeStyle = "gray";
ctxt.moveTo(0, y);
ctxt.lineTo(width, y);
ctxt.stroke();
}
// save this drawing to the scratch canvas.
sctxt.drawImage(canvas, 0, 0);
}
</script>
我们为此使用了两个画布,因此我们有一个画布来存储准备好的网格和轴。我们将频率轴(x 轴)绘制为从音频环境的奈奎斯特频率向下 10 个八度音阶。我们绘制了从-60 dB 到 40 dB 的增益轴(y 轴)。图 6-9 显示了结果。
图 6-9 。频率增益图
现在,我们需要做的就是将滤波器的频率响应绘制到这张图中。清单 6-12 显示了用于此的函数。
清单 6-12 。应用于音频源的不同双二阶滤波器类型
function drawGraph() {
// grab the axis and grid from scratch canvas.
ctxt.clearRect(0, 0, 600, height);
ctxt.drawImage(scratch, 0, 0);
// grab the frequency response data.
var frequencyHz = new Float32Array(width);
var magResponse = new Float32Array(width);
var phaseResponse = new Float32Array(width);
for (var i = 0; i < width; ++i) {
var f = i / width;
// Convert to log frequency scale (octaves).
f = nyquist * Math.pow(2.0, nrOctaves * (f - 1.0));
frequencyHz[i] = f;
}
bass.getFrequencyResponse(frequencyHz, magResponse, phaseResponse);
// draw the frequency response.
ctxt.beginPath();
ctxt.strokeStyle = "red";
ctxt.lineWidth = 3;
for (var i = 0; i < width; ++i) {
var response = magResponse[i];
var dbResponse = 20.0 * Math.log(response) / Math.LN10;
var x = i;
var y = dbToY(dbResponse);
if ( i == 0 ) {
ctxt.moveTo(x, y);
} else {
ctxt.lineTo(x, y);
}
}
ctxt.stroke();
}
首先,我们从草稿画布中抓取前面的图形,并将其添加到一个空的画布中。然后,我们准备想要检索响应的频率数组,并对其调用getFrequencyResponse()
方法。最后,我们通过绘制从数值到数值的直线来绘制频率响应曲线。对于完整的例子,组合清单 6-10 、清单 6-11 清单和清单 6-12 清单,并调用 play 事件处理程序中的drawGraph()
函数(参见http://html5videoguide.net
)。
图 6-10 显示了清单 6-10 中的低通滤波器的结果。
图 6-10 。清单 6-10 的低通滤波器频率响应
波形节点接口
WaveShaperNode
代表非线性失真效果,使用AudioContext
的createWaveShaper()
方法创建。失真效果通过压缩或削波声波的波峰来产生“温暖”和“肮脏”的声音,从而产生大量添加的泛音。此AudioNode
使用曲线将波形失真应用于信号。
interface WaveShaperNode : AudioNode {
attribute Float32Array? curve;
attribute OverSampleType oversample;
};
curve
数组包含整形曲线的采样值。
oversample
参数指定在应用整形曲线时,应该对输入信号应用何种类型的过采样。
enum OverSampleType {
"none",
"2x",
"4x"
};
默认值为“无”,表示曲线直接应用于输入样本。值“2x”或“4x”可以通过避免一些锯齿来提高处理质量,其中“4x”产生最高质量。
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “麦克斯” |
| 频道计数 | 输出中的数量与输入中的数量相同 |
| 渠道解释 | “扬声器” |
在这里,造型曲线是需要理解的重要概念。它由 x 轴区间[-1]中的曲线提取组成;1]的值仅在-1 和 1 之间。值为 0 时,曲线的值为 0。默认情况下,curve
数组为空,这意味着WaveShaperNode
不会对输入声音信号进行任何修改。
创造一个好的塑形曲线是一种艺术形式,需要对数学有很好的理解。这里有一个关于波形如何工作的很好的解释:http://music.columbia.edu/cmc/musicandcomputers/chapter4/04_06.php
。
我们将使用 y = 0.5x 3 作为我们的波形整形器。图 6-11 显示了它的形状。
图 6-11 。波形整形器示例
清单 6-13 展示了如何将这个函数应用到一个过滤图中。
清单 6-13 。将波形整形器应用于输入信号
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
function makeDistortionCurve() {
var n_samples = audioCtx.sampleRate;
var curve = new Float32Array(n_samples);
var x;
for (var i=0; i < n_samples; ++i ) {
x = i * 2 / n_samples - 1;
curve[i] = 0.5 * Math.pow(x, 3);
}
return curve;
};
var distortion = audioCtx.createWaveShaper();
distortion.curve = makeDistortionCurve();
distortion.oversample = ’4x’;
mediaElement.addEventListener(’play’, function() {
// create filter graph
source.connect(distortion);
distortion.connect(audioCtx.destination);
});
在makeDistortionCurve()
函数中,我们通过在AudioContext
的samplingRate
处采样 0.5x 3 函数来创建波形曲线。然后,我们用整形曲线和 4 倍过采样创建波形整形器,并将滤波器图形放在音频输入文件中。回放时,您会注意到声音变得安静了很多,这是因为这个特定的波形整形器只有-0.5 到 0.5 之间的值。
立体面板节点接口
StereoPannerNode
表示一个简单的立体声声相器节点,可以用来向左或向右移动音频流,它是用AudioContext
的createStereoPanner()
方法创建的。
interface StereoPannerNode : AudioNode {
readonly attribute AudioParam pan;
};
它使给定的声相位置在传播到输出之前应用于输入数据。
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “最大箝位” |
| 频道计数 | Two |
| 渠道解释 | “扬声器” |
该节点始终处理两个通道,channelCountMode
始终为“箝位最大值”来自具有更少或更多通道的节点的连接将被适当地向上混合或向下混合。
pan
参数描述输入在输出立体图像中的新位置。
- -1 表示最左侧
- +1 表示完全正确
它的默认值是 0,其标称范围是从-1 到 1。这个参数是一个 a-rate。
声相可以随时间改变,从而产生移动声源的效果(例如,从左到右)。这是通过修改左右声道的增益来实现的。
清单 6-14 显示了一个通过滑块操纵音频信号的声相位置的例子。确保释放滑块,这样当您自己尝试时,滑块的值实际上会改变。过滤图由一个MediaElementSourceNode
、一个StereoPannerNode
和一个AudioDestinationNode
组成。
清单 6-14 。操纵音频信号的声相位置
<audio autoplay controls src="audio/Shivervein_Razorpalm.wav"></audio>
<input type="range" min="-1" max="1" step="0.05" value="0"/>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
var panNode = audioCtx.createStereoPanner();
source.connect(panNode);
panNode.connect(audioCtx.destination);
var slider = document.getElementsByTagName(’input’)[0];
slider.addEventListener(’change’, function() {
panNode.pan.value = slider.value;
});
</script>
回放时,您可能想要使用耳机来更好地感受滑块移动如何影响信号的立体声位置。
卷积器节点接口
ConvolverNode
代表一个处理节点,对AudioBuffer
进行线性卷积,用AudioContext
的createConvolver()
方法创建。
interface ConvolverNode : AudioNode {
attribute AudioBuffer? buffer;
attribute boolean normalize;
};
我们可以想象一个线性卷积器代表一个房间的声学特性,而ConvolverNode
的输出代表该房间中输入信号的混响。声学特性存储在一种叫做脉冲响应的东西中。
AudioBuffer buffer
属性包含单声道、立体声或四声道脉冲响应,由ConvolverNode
用来创建混响效果。它是作为音频文件本身提供的。
normalize
属性决定来自buffer
的脉冲响应是否将通过等功率归一化进行缩放。默认是true
。
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “最大箝位” |
| 频道计数 | Two |
| 渠道解释 | “扬声器” |
ConvolverNode
可以接受单声道音频输入,并应用双声道或四声道脉冲响应,以产生立体声音频输出信号。来自具有更少或更多通道的节点的连接将被适当地向上混合或向下混合,但是最多允许两个通道。
清单 6-15 展示了一个应用于音频文件的三种不同脉冲响应的例子。过滤图由一个MediaElementSourceNode
、一个ConvolverNode
和一个AudioDestinationNode
组成。
清单 6-15 。对音频信号应用三种不同的卷积
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
var convolver = audioCtx.createConvolver();
// Pre-Load the impulse responses
var impulseFiles = [
"audio/filter-telephone.wav",
"audio/kitchen.wav",
"audio/cardiod-rear-levelled.wav"
];
var impulseResponses = new Array();
var allLoaded = 0;
function loadFile(url, index) {
var request = new XMLHttpRequest();
function requestData(url) {
request.open(’GET’, url, true);
request.responseType = ’arraybuffer’;
request.send();
}
function receivedData() {
if ((request.status === 200 || request.status === 206)
&& request.readyState === 4) {
var audioData = request.response;
audioCtx.decodeAudioData(audioData,
function(buffer) {
impulseResponses[index] = buffer;
if (++allLoaded == impulseFiles.length) {
createFilterGraph();
}
},
function(error) {
"Error with decoding audio data" + error.err
}
);
}
}
request.addEventListener(’load’, receivedData, false);
requestData(url);
}
for (i = 0; i < impulseFiles.length; i++) {
loadFile(impulseFiles[i], i);
}
// create filter graph
function createFilterGraph() {
source.connect(convolver);
convolver.buffer = impulseResponses[0];
convolver.connect(audioCtx.destination);
}
var radioButtons = document.getElementsByTagName(’input’);
for (i = 0; i < radioButtons.length; i++){
radioButtons[i].addEventListener(’click’, function() {
convolver.buffer = impulseResponses[this.value];
});
}
你会注意到我们正在通过XMLHttpRequest
加载三个脉冲响应,如清单 6-5 中的。然后,我们将它们存储在一个数组中,当用户在输入单选按钮之间切换时,我们可以在它们之间进行切换。我们只能在所有脉冲响应都已加载(即 allLoaded = 2)后将滤波器图放在一起。
它的 HTML 将有三个输入元素作为单选按钮,用于在不同的脉冲响应之间切换。当您玩这个例子时,您会注意到“电话”、“厨房”和“仓库”脉冲响应之间的混响差异。
ChannelSplitterNode 和 ChannelMergeNode 接口
ChannelSplitterNode
和ChannelMergerNode
表示用于在滤波器图中将音频流的各个声道分开和合并在一起的AudioNode
。
ChannelSplitterNode
是用AudioContext
的createChannelSplitter()
方法创建的,该方法带一个可选的numberOfOutputs
参数,该参数表示扇出AudioNode
s 的大小,默认为 6。哪个输出实际上具有音频数据取决于ChannelSplitterNode
的输入音频信号中可用的通道数量。例如,将一个立体声信号扇出到六个输出,只会产生两个带信号的输出,其余的都是无声的。
ChannelMergerNode
与ChannelSplitterNode
相反,使用AudioContext
的createChannelMerger()
方法创建,该方法采用一个可选的numberOfInputs
参数,表示扇入AudioNode
的大小。默认情况下为 6,但并非所有扇入都需要连接,也并非所有扇入都需要包含音频信号。例如,扇入六个输入,其中只有前两个具有立体声音频信号,每个输入创建六声道流,其中第一和第二输入被下混合为单声道,其余声道为静音。
interface ChannelSplitterNode : AudioNode {};
interface ChannelMergerNode : AudioNode {};
| | ChannelSplitterNode
| ChannelMergeNode
|
| 输入数量 | one | n(默认值:6) |
| 产出数量 | n(默认值:6) | one |
| 通道计数模式 | “麦克斯” | “麦克斯” |
| 频道计数 | 扇出至多个单声道输出 | 扇入多个缩混单声道输入 |
| 渠道解释 | “扬声器” | “扬声器” |
对于ChannelMergerNode
,channelCount
和channelCountMode
属性不可更改——所有输入都被视为单声道信号。
ChannelSplitterNode
和ChannelMergerNode
的一个应用是进行“矩阵混合”,其中每个通道的增益被单独控制。
清单 6-16 显示了我们示例音频文件的矩阵混合示例。您可能希望使用耳机来更好地听到左右声道的单独音量控制。
清单 6-16 。对音频文件的左右声道应用不同的增益
<audio autoplay controls src="audio/Shivervein_Razorpalm.wav"></audio>
<p>Left Channel Gain:
<input type="range" min="0" max="1" step="0.1" value="1"/>
</p>
<p>Right Channel Gain:
<input type="range" min="0" max="1" step="0.1" value="1"/>
</p>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
var splitter = audioCtx.createChannelSplitter(2);
var merger = audioCtx.createChannelMerger(2);
var gainLeft = audioCtx.createGain();
var gainRight = audioCtx.createGain();
// filter graph
source.connect(splitter);
splitter.connect(gainLeft, 0);
splitter.connect(gainRight, 0);
gainLeft.connect(merger, 0, 0);
gainRight.connect(merger, 0, 1);
merger.connect(audioCtx.destination);
var sliderLeft = document.getElementsByTagName(’input’)[0];
sliderLeft.addEventListener(’change’, function() {
gainLeft.gain.value = sliderLeft.value;
});
var sliderRight = document.getElementsByTagName(’input’)[1];
sliderRight.addEventListener(’change’, function() {
gainRight.gain.value = sliderRight.value;
});
</script>
这个例子很简单,两个输入滑块分别控制两个增益节点的音量,每个通道一个。需要理解的一件事是在AudioNode
上使用AudioNode
的connect()
方法的第二个和第三个参数,它们允许连接ChannelSplitterNode
或ChannelMergerNode
的独立通道。
图 6-12 显示了该示例的过滤图。
图 6-12 。左右声道音量控制的滤波图
DynamicCompressorNode 接口
DynamicCompressorNode
提供压缩效果,降低信号中最响亮部分的音量,以防止多个声音一起播放和多路复用时可能发生的削波和失真。总体来说,可以实现更响亮、更丰富、更饱满的声音。它是用AudioContext
的createDynamicCompressor()
方法创建的。
interface DynamicsCompressorNode : AudioNode {
readonly attribute AudioParam threshold;
readonly attribute AudioParam knee;
readonly attribute AudioParam ratio;
readonly attribute float reduction;
readonly attribute AudioParam attack;
readonly attribute AudioParam release;
};
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “明确” |
| 频道计数 | Two |
| 渠道解释 | “扬声器” |
threshold
参数提供分贝值,超过该值压缩将开始生效。其默认值为-24,标称范围为-100 到 0。
knee
参数提供一个分贝值,代表曲线平滑过渡到压缩部分的阈值以上的范围。其默认值为 30,标称范围为 0 到 40。
ratio
参数代表输出变化 1 dB 时输入所需的 dB 变化量。其默认值为 12,标称范围为 1 到 20。
reduction
参数代表压缩器当前应用于信号的增益降低量,单位为 dB。如果没有输入信号,该值将为 0(无增益降低)。
attack
参数代表将增益降低 10 dB 的时间量(秒)。其默认值为 0.003,标称范围为 0 到 1。
release
参数表示增益增加 10 dB 所需的时间(秒)。其默认值为 0.250,标称范围为 0 到 1。
所有参数都是 k-rate。
清单 6-17 显示了一个动态压缩的例子。
清单 6-17 。音频信号的动态压缩
<audio autoplay controls src="audio/Shivervein_Razorpalm.wav"></audio>
<p>Toggle Compression: <button value="0">Off</button></p>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
// Create a compressor node
var compressor = audioCtx.createDynamicsCompressor();
compressor.threshold.value = -50;
compressor.knee.value = 20;
compressor.ratio.value = 12;
compressor.reduction.value = -40;
mediaElement.addEventListener(’play’, function() {
source.connect(audioCtx.destination);
});
var button = document.getElementsByTagName(’button’)[0];
button.addEventListener(’click’, function() {
if (this.value == 1) {
this.value = 0;
this.innerHTML = "Off";
source.disconnect(audioCtx.destination);
source.connect(compressor);
compressor.connect(audioCtx.destination);
} else {
this.value = 1;
this.innerHTML = "On";
source.disconnect(compressor);
compressor.disconnect(audioCtx.destination);
source.connect(audioCtx.destination);
}
});
</script>
在这个例子中,通过点击一个按钮,可以在过滤器图形中包括和排除压缩机。
图 6-13 描绘了所使用的压缩机。您可以看到,在-50 dB 以下,没有应用压缩。在接下来的 20 dB 内,平滑过渡到压缩曲线。该比率决定了超过阈值时应用的压缩量,我们选择输入变化 12 dB,输出变化 1 dB。1 dB 的变化不会导致任何变化,比率值越大,压缩图变平的速度越快。
图 6-13 。示例中使用的动态压缩图
总体效果是音频信号的音量降低,但只是在先前的高音量部分,而不是在较安静的部分。
我们对AudioNode
接口的了解到此结束,这些接口是操纵音频信号不同方面的标准功能,包括增益、动态、延迟、波形、通道、立体声位置和频率滤波器。
3D 空间化和平移
在本节中,我们将了解音频信号的三维定位,这在游戏中特别有用,因为游戏中需要根据听众的位置将多个信号以不同方式混合在一起。Web Audio API 带有内置的硬件加速定位音频功能。
我们处理两个构造来操纵 3D 音频信号:听众的位置和PannerNode
,它是一个过滤器节点,用来操纵声音相对于听众的位置。听者的位置由AudioContext
中的AudioListener
属性描述,而PannerNode
是通过也是AudioContext
一部分的函数创建的。
[Constructor] interface AudioContext : EventTarget {
...
readonly attribute AudioListener listener;
PannerNode createPanner ();
...
}
AudioListener 接口
该界面表示收听音频场景的人的位置和方向。
interface AudioListener {
void setPosition (float x, float y, float z);
void setOrientation (float xFront, float yFront, float zFront,
float xUp, float yUp, float zUp);
void setVelocity (float x, float y, float z);
};
AudioContext
假设听者所处的三维右手笛卡尔坐标空间(见图 6-14 )。默认情况下,侦听器站在(0,0,0)处。
图 6-14 。听者所处的右手笛卡尔坐标空间
setPosition()
方法允许我们改变位置。虽然坐标是无单位的,但通常会指定相对于特定空间尺寸的位置,并使用百分比值来指定位置。
setOrientation()
方法允许我们在 3D 笛卡尔坐标空间中改变听者耳朵指向的方向。提供了一个Front
位置和一个Up
位置。用简单的人类术语来说,Front
位置代表人的鼻子指向哪个方向,默认为(0,0,-1),表示 z 方向与耳朵指向的位置相关。Up
位置代表人的头顶所指的方向,默认为(0,1,0),表示 y 方向与人的身高有关。图 6-14 也显示了Front
和Up
位置。
setVelocity()
方法允许我们改变听者的速度,这控制了 3D 空间中行进的方向和速度。该速度相对于音频源的速度可用于确定要应用多少多普勒频移(音高变化)。默认值为(0,0,0),表示侦听器是静止的。
用于该矢量的单位是米/秒,并且独立于用于位置和方向矢量的单位。例如,值(0,0,17)表示收听者以 17 米/秒的速度在 z 轴方向上移动。
清单 6-18 显示了一个改变听众位置和方向的例子。默认情况下,音频声音也位于(0,0,0)。
清单 6-18 。改变听者的位置和方向
<p>Position:
<input type="range" min="-1" max="1" step="0.1" value="0" name="pos0"/>
<input type="range" min="-1" max="1" step="0.1" value="0" name="pos1"/>
<input type="range" min="-1" max="1" step="0.1" value="0" name="pos2"/>
</p>
<p>Orientation:
<input type="range" min="-1" max="1" step="0.1" value="0" name="dir0"/>
<input type="range" min="-1" max="1" step="0.1" value="0" name="dir1"/>
<input type="range" min="-1" max="1" step="0.1" value="-1" name="dir2"/>
</p>
<p>Elevation:
<input type="range" min="-1" max="1" step="0.1" value="0" name="hei0"/>
<input type="range" min="-1" max="1" step="0.1" value="1" name="hei1"/>
<input type="range" min="-1" max="1" step="0.1" value="0" name="hei2"/>
</p>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var source = audioCtx.createBufferSource();
var request = new XMLHttpRequest();
var url = ’audio/ticking.wav’;
request.addEventListener(’load’, receivedData, false);
requestData(url);
var inputs = document.getElementsByTagName(’input’);
var pos = [0, 0, 0]; // position
var ori = [0, 0, -1]; // orientation
var ele = [0, 1, 0]; // elevation
for (i=0; i < inputs.length; i++) {
var elem = inputs[i];
elem.addEventListener(’change’, function() {
var type = this.name.substr(0,3);
var index = this.name.slice(3);
var value = parseFloat(this.value);
switch (type) {
case ’pos’:
pos[index] = value;
audioCtx.listener.setPosition(pos[0], pos[1], pos[2]);
break;
case ’ori’:
ori[index] = value;
audioCtx.listener.setOrientation(ori[0], ori[1], ori[2],
ele[0], ele[1], ele[2]);
break;
case ’ele’:
ele[index] = value;
audioCtx.listener.setOrientation(ori[0], ori[1], ori[2],
ele[0], ele[1], ele[2]);
break;
default:
console.log(’no match’);
}
});
}
</script>
在这个例子中,我们使用清单 6-5 中介绍的函数将一个循环声音加载到一个AudioBuffer
中,并且我们操纵三个参数的三维。
注意感谢 Izkhanilov 在 freesound 的知识共享许可下提供了“滴答响的时钟. wav”样本(见
www.freesound.org/people/Izkhanilov/sounds/54848/
)。
有趣的是,当您复制我们的示例时,您会注意到参数变化对声音回放没有影响。这是因为没有明确指定声源的位置。因此,我们认为AudioContext
假设听者和声源位于同一位置。它需要一个PannerNode
来指定声源的位置。
PannerNode 接口
这个接口代表一个处理节点,它在 3D 空间中相对于听众定位/空间化输入的音频流。它是用AudioContext
的createPanner()
方法创建的。
interface PannerNode : AudioNode {
void setPosition (float x, float y, float z);
void setOrientation (float x, float y, float z);
attribute PanningModelType panningModel;
attribute DistanceModelType distanceModel;
attribute float refDistance;
attribute float maxDistance;
attribute float rolloffFactor;
attribute float coneInnerAngle;
attribute float coneOuterAngle;
attribute float coneOuterGain;
};
一种思考平移者和听者的方式是考虑一个游戏环境,其中对手正在 3D 空间中奔跑,声音来自场景中的各种来源。这些源中的每一个都有一个与之相关联的PannerNode
。
物体有一个方向向量,代表声音发出的方向。此外,它们有一个音锥来代表声音的方向性。例如,声音可以是全向的,在这种情况下,无论它的方向如何,它都可以在任何地方听到,或者它可以是更具方向性的,只有当它面对听众时才能听到。在渲染过程中,PannerNode
计算方位角(听众对声源的角度)和仰角(听众上方或下方的高度)。浏览器使用这些值来呈现空间化效果。
| 输入数量 | one |
| 产出数量 | 1(立体声) |
| 通道计数模式 | “最大箝位” |
| 频道计数 | 2(固定) |
| 渠道解释 | “扬声器” |
PannerNode
的输入可以是单声道(一个声道)或立体声(两个声道)。来自具有更少或更多通道的节点的连接将被适当地向上混合或向下混合。此节点的输出被硬编码为立体声(两个声道),当前无法配置。
setPosition()
方法设置音频源相对于听众的位置。默认值为(0,0,0)。
setOrientation()
方法描述了音频源在 3D 笛卡尔坐标空间中指向的方向。根据声音的方向性(由圆锥体属性控制),远离听众的声音可以非常安静或完全无声。默认值为(1,0,0)。
panningModel
属性指定这个PannerNode
使用哪个平移模型。
enum PanningModelType {
"equalpower",
"HRTF"
};
平移模型描述如何计算声音空间化。“等功率”模式使用等功率平移,忽略仰角。HRTF (头部相关传递函数)模型使用与来自人的测量脉冲响应的卷积,从而模拟人的空间化感知。panningModel
默认为 HRTF。
distanceModel
属性决定了当音频源远离听众时,将使用哪种算法来降低其音量。
enum DistanceModelType {
"linear",
"inverse",
"exponential"
};
“线性”模型假设随着声源远离听众,增益线性降低。“逆”模型假设增益降低越来越小。“指数”模型假设增益降低越来越大。distanceModel
默认为“反相”
refDistance
属性包含一个参考距离,用于随着源远离收听者而减小音量。默认值为 1。
maxDistance
属性包含源和收听者之间的最大距离,超过该距离后,音量将不再降低。默认值为 10,000。
rolloffFactor
属性描述了当源远离收听者时音量降低的速度。默认值为 1。
coneInnerAngle
、coneOuterAngle
和coneOuterGain
一起描述了一个内部体积减少比外部少得多的圆锥体。有一个内锥体和一个外锥体,它们将声音强度描述为来自声源方向向量的声源/听者角度的函数。因此,直接指向听众的声源会比离轴指向的声源更响。
图 6-15 直观地描述了音盆概念。
图 6-15 。相对于收听者的全景声节点的源锥体的可视化
coneInnerAngle
提供了一个角度,以度为单位,在该角度范围内,体积不会减少。默认值是 360,使用的值是模 360。
coneOuterAngle
提供了一个角度,以度为单位,超出该角度,音量将减小到恒定值coneOuterGain
。默认值为 360,该值以 360 为模。
coneOuterGain
提供了coneOuterAngle
之外的音量减小量。默认值为 0,该值以 360 为模。
让我们扩展清单 6-18 中的例子,并引入一个PannerNode
。这具有将声源定位在不同于听众位置的固定位置的效果。我们还包括一个音盆,这样我们可以更容易地听到增益降低的影响。参见清单 6-19 中的变化。
清单 6-19 。引入声源的位置和声锥
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var source = audioCtx.createBufferSource();
var panner = audioCtx.createPanner();
panner.coneOuterGain = 0.5;
panner.coneOuterAngle = 180;
panner.coneInnerAngle = 90;
source.connect(panner);
panner.connect(audioCtx.destination);
现在,当我们改变位置、方向和仰角时,我们相对于声源移动听者,声源保持在声音场景的中间。例如,当我们将位置的 x 值向右移动时,声音向我们的左侧移动,我们将声音向右侧避开。一旦声音在我们的左边,当我们在-0.1 和+0.1 之间移动方向的 z 值时,声音在左右之间移动-在 0 时,我们面对声音,在+0.1 时,我们将我们的右侧转向它,在-0.1 时,我们将我们的左侧转向它。
请注意,我们实际上并没有移动声源的位置,而只是移动了听者的位置。你可以使用PannerNode
的setPosition()
和setOrientation()
来实现,你可以用多种声音来实现。我们将把这作为一个练习留给读者。
注意Web Audio API 规范用于为
PannerNode
提供一个setVelocity()
方法,该方法将计算移动声源和听众的多普勒频移。这已被否决,并将在版本 45 后从 Chrome 中删除。有计划引入一个新的SpatializerNode
界面来取代它。现在,你需要自己计算多普勒频移,可能使用DelayNode
接口或者改变playbackRate
。
音频数据的 JavaScript 操作
Web Audio API 规范目前的实现状态包括一个名为ScriptProcessorNode
的接口,可以直接使用 JavaScript 生成、处理或分析音频。这种节点类型已被弃用,目的是用AudioWorker
接口替换它,但我们仍将解释它,因为它在当前的浏览器中实现,而AudioWorker
接口没有。
ScriptProcessorNode
和AudioWorker
界面的区别在于,第一个界面运行在主浏览器线程上,因此必须与布局、渲染和浏览器中进行的大多数其他处理共享处理时间。Web Audio API 的所有其他音频节点都在单独的线程上运行,这使得音频更有可能不受其他大任务的干扰。这将随着AudioWorker
而改变,它也将在音频线程上运行 JavaScript 音频处理。它将能够以更少的延迟运行,因为它避免了线程边界的改变和必须与主线程共享资源。
这听起来很棒,但是现在我们不能使用AudioWorker
,因此将首先查看ScriptProcessorNode
。
ScriptProcessorNode 接口
该接口允许编写您自己的 JavaScript 代码来生成、处理或分析音频,并将其集成到滤波器图中。
通过AudioContext
上的createScriptProcessor()
方法创建一个ScriptProcessorNode
:
[Constructor] interface AudioContext : EventTarget {
...
ScriptProcessorNode createScriptProcessor(
optional unsigned long bufferSize = 0 ,
optional unsigned long numberOfInputChannels = 2 ,
optional unsigned long numberOfOutputChannels = 2 );
...
}
它只接受可选参数,建议将这些参数的设置留给浏览器。然而,以下是他们的解释:
- a
bufferSize
以 256,512,1,024,2,048,4,096,8,192,16,384 个样本帧为单位。这控制了发送音频处理事件的频率以及每个调用中需要处理的样本帧的数量。 numberOfInputChannels
默认为 2,但最多可达 32。numberOfOutputChannels
默认为 2,但最多可达 32。
ScriptProcessorNode
的界面定义如下:
interface ScriptProcessorNode : AudioNode {
attribute EventHandler onaudioprocess;
readonly attribute long bufferSize;
};
bufferSize
属性反映了创建节点时的缓冲区大小,而onaudioprocess
将一个 JavaScript 事件处理程序与节点关联起来,当节点被激活时就会调用这个处理程序。处理程序接收的事件是一个AudioProcessingEvent
。
interface AudioProcessingEvent : Event {
readonly attribute double playbackTime;
readonly attribute AudioBuffer inputBuffer;
readonly attribute AudioBuffer outputBuffer;
};
它包含以下只读数据:
- 一个
playbackTime
,这是音频将在与AudioContext
的currentTime
相同的时间坐标系中播放的时间。 - 一个
inputBuffer
,包含输入音频数据,其声道数等于createScriptProcessor()
方法的numberOfInputChannels
参数。 - 一个
outputBuffer
,用于保存事件处理程序的输出音频数据。它的通道数必须等于createScriptProcessor()
方法的numberOfOutputChannels
参数。
ScriptProcessorNode
不改变其通道或输入数量。
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “明确” |
| 频道计数 | 输入通道的数量 |
| 渠道解释 | “扬声器” |
使用ScriptProcessorNode
的一个简单例子是给音频样本添加一些随机噪声。清单 6-20 显示了一个这样的例子。
清单 6-20 。在 ScriptProcessorNode 中向音频文件添加随机噪声
<audio autoplay controls src="audio/ticking.wav"></audio>
<script>
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
var noiser = audioCtx.createScriptProcessor();
source.connect(noiser);
noiser.connect(audioCtx.destination);
noiser.onaudioprocess = function(event) {
var inputBuffer = event.inputBuffer;
var outputBuffer = event.outputBuffer;
for (var channel=0; channel < inputBuffer.numberOfChannels; channel++) {
var inputData = inputBuffer.getChannelData(channel);
var outputData = outputBuffer.getChannelData(channel);
for (var sample = 0; sample < inputBuffer.length; sample++) {
outputData[sample] = inputData[sample] + (Math.random() * 0.01);
}
}
};
</script>
当我们将输入数据复制到输出数据数组时,我们将 0 到 1 之间的一个随机数的 10%添加到输入数据中,从而创建一个带有一些白噪声的输出样本。
注意你可能想看看规范中新的
AudioWorker
接口以及它是如何取代ScriptProcessorNode
的。我们不能在这里描述它,因为在我写这篇文章的时候,它每天都在变化。原则是创建一个 JavaScript 文件,其中包含一个AudioWorkerNode
应该执行的脚本,然后在现有的AudioContext
上调用一个createAudioWorker()
函数,将这个脚本交给一个Worker
,后者在一个单独的线程中执行它。在AudioWorkerNode
和AudioContext
之间会引发事件来处理每个线程中的状态变化,并且能够向AudioWo
rkerNode
提供AudioParams
。
离线音频处理
OfflineAudioContext
接口是一种AudioContext
接口,它不将滤波器图形的音频输出呈现给设备硬件,而是呈现给一个AudioBuffer
。这使得处理音频数据的速度可能比实时更快,如果您只想分析音频流的内容(例如,当检测节拍时),这非常有用。
[Constructor(unsigned long numberOfChannels,
unsigned long length,
float sampleRate)]
interface OfflineAudioContext : AudioContext {
attribute EventHandler oncomplete;
Promise<AudioBuffer> startRendering ();
};
构建一个OfflineAudioContext
的工作方式类似于用AudioContext
的createBuffer()
方法创建一个新的AudioBuffer
,并采用相同的三个参数。
numberOfChannels
属性包含AudioBuffer
应该拥有的离散通道的数量。length
属性包含样本帧中音频资源的长度。sampleRate
属性包含音频资产的采样率。
OfflineAudioContext
提供了一个oncomplete
事件处理程序,在处理完成时调用。
它还提供了一个startRendering()
方法。当OfflineAudioContext
被创建时,它处于“暂停”状态。对该函数的调用启动了过滤器图形的处理。
使用OfflineAudioContext
的一个简单例子是从一个音频文件中抓取音频数据到一个OfflineAudioContext
中,而不打扰可能正在做其他工作的将军AudioContext
。清单 6-21 展示了如何通过调整清单 6-5 来实现这一点。
清单 6-21 。在 ScriptProcessorNode 中向音频文件添加随机噪声
// AudioContext that decodes data
var offline = new window.OfflineAudioContext(2,44100*20,44100);
var source = offline.createBufferSource();
var offlineReady = false;
// AudioContext that renders data
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var sound;
var audioBuffer;
var request = new XMLHttpRequest();
var url = ’audio/transition.wav’;
function receivedData() {
if ((request.status === 200 || request.status === 206)
&& request.readyState === 4) {
var audioData = request.response;
offline.decodeAudioData(audioData,
function(buffer) {
source.buffer = buffer;
source.connect(offline.destination);
source.start(0);
offlineReady = true;
},
function(error) {
"Error with decoding audio data" + error.err
}
);
}
}
request.addEventListener(’load’, receivedData, false);
requestData(url);
function startPlayback() {
sound = audioCtx.createBufferSource();
sound.buffer = audioBuffer;
sound.connect(audioCtx.destination);
sound.start(0);
}
var stop = document.getElementsByTagName(’button’)[0];
stop.addEventListener(’click’, function() {
sound.stop();
});
var start = document.getElementsByTagName(’button’)[1];
start.addEventListener(’click’, function() {
if (!offlineReady) return;
offline.startRendering().then(function(renderedBuffer) {
audioBuffer = renderedBuffer;
startPlayback();
}).catch(function(err) {
// audioData has already been rendered
startPlayback();
});
});
我们在清单 6-5 的例子中添加了第二个按钮,现在手动启动音频文件。下载音频文件后,离线上下文正在对其进行解码并开始呈现(当我们单击 start 按钮时)。在渲染例程中,我们保存解码后的AudioBuffer
数据,以便在稍后阶段重新加载。就是这个AudioBuffe
r
数据,我们交给直播AudioContext
回放。
音频数据可视化
我们需要了解的最后一个接口是AnalyserNode
接口。该接口表示能够提供实时频率和时域样本信息的节点。这些节点不会对直接通过的音频流进行任何更改。因此,它们可以放在过滤器图中的任何位置。该界面的主要用途是可视化音频数据。
通过AudioContext
上的createAnalyser()
方法创建一个AnalyserNode
:
[Constructor] interface AudioContext : EventTarget {
...
AnalyserNode createAnalyser ();
...
}
AnalyserNode
的界面定义如下:
interface AnalyserNode : AudioNode {
attribute unsigned long fftSize;
readonly attribute unsigned long frequencyBinCount;
attribute float minDecibels;
attribute float maxDecibels;
attribute float smoothingTimeConstant;
void getFloatFrequencyData (Float32Array array);
void getByteFrequencyData (Uint8Array array);
void getFloatTimeDomainData (Float32Array array);
void getByteTimeDomainData (Uint8Array array);
};
这些属性包含以下信息:
fftSize
:用于分析的缓冲区大小。它必须是 32 到 32,768 范围内的 2 的幂,默认值为 2,048。frequencyBinCount
:FFT(快速傅立叶变换)大小一半的固定值。minDecibels
、maxDecibels
:将 FFT 分析数据换算成无符号字节值的功率值范围。默认范围是从minDecibels
= -100 到maxDecibels
= -30。smoothingTimeConstant
:介于 0 和 1 之间的值,表示平滑结果的滑动窗口的大小。0 表示没有时间平均,因此结果波动很大。默认值为 0.8。
这些方法将下列数据复制到提供的数组中:
getFloatFrequencyData
、getByteFrequencyData
:不同数据类型的当前频率数据。如果数组的元素比frequencyBinCount
少,多余的元素将被丢弃。如果数组的元素比frequencyBinCount
多,多余的元素将被忽略。getFloatTimeDomainData
、getByteTimeDomainData
:当前时域(波形)数据。如果数组中的元素少于fftSize
的值,多余的元素将被丢弃。如果数组的元素多于fftSize
,超出的元素将被忽略。
AnalyserNode
不改变其通道或输入数量,输出可以不连接:
| 输入数量 | one |
| 产出数量 | one |
| 通道计数模式 | “麦克斯” |
| 频道计数 | one |
| 渠道解释 | “扬声器” |
清单 6-22 显示了一个将波形渲染到画布上的简单例子。
清单 6-22 。呈现音频上下文的波形数据
<audio autoplay controls src="audio/ticking.wav"></audio>
<canvas width="512" height="200"></canvas>
<script>
// prepare canvas for rendering
var canvas = document.getElementsByTagName("canvas")[0];
var sctxt = canvas.getContext("2d");
sctxt.fillRect(0, 0, 512, 200);
sctxt.strokeStyle = "#FFFFFF";
sctxt.lineWidth = 2;
// prepare audio data
var audioCtx = new (window.AudioContext || window.webkitAudioContext)();
var mediaElement = document.getElementsByTagName(’audio’)[0];
var source = audioCtx.createMediaElementSource(mediaElement);
// prepare filter graph
var analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.1;
source.connect(analyser);
analyser.connect(audioCtx.destination);
// data from the analyser node
var buffer = new Uint8Array(analyser.frequencyBinCount);
function draw() {
analyser.getByteTimeDomainData(buffer);
// do the canvas painting
var width = canvas.width;
var height = canvas.height;
var step = parseInt(buffer.length / width);
sctxt.fillRect(0, 0, width, height);
sctxt.drawImage(canvas, 0, 0, width, height);
sctxt.beginPath();
sctxt.moveTo(0, buffer[0] * height / 256);
for(var i=1; i< width; i++) {
sctxt.lineTo(i, buffer[i*step] * height / 256);
}
sctxt.stroke();
window.requestAnimationFrame(draw);
}
mediaElement.addEventListener(’play’, draw , false);
</script>
我们使用一个画布来绘制波浪,并用黑色背景和白色绘图颜色来准备它。我们为样本输入实例化了AudioContext
和音频元素,准备好analyser
并把它们都连接到一个过滤图。
一旦音频元素开始回放,我们就开始绘图,从analyser
中抓取波形字节。这些通过一个getByteTimeDomainData()
方法公开,该方法填充一个提供的Uint8Array
。我们获取这个数组,从先前的绘图中清除画布,并将这个新数组作为连接所有值的线绘制到画布中。然后在一个requestAnimationFrame()
调用中再次调用draw()
方法来抓取下一个无符号 8 位字节数组进行显示。这将连续地将波形绘制到画布上。
使用requestAnimationFrame
的另一种更传统的方法是使用超时为 0 的setTimeout()
函数。我们建议使用requestAnimationFrame
进行所有绘图,因为它是为渲染而构建的,并确保在下一个可能的屏幕重绘机会正确安排绘图。
图 6-16 显示了运行清单 6-22 的结果。
图 6-16 。在 Web 音频 API 中呈现音频波形
我们对 Web 音频 API 的探索到此结束。
摘要
在这一章中,我们了解了现有的关于音频 API 的提议,它提供了对样本的访问,无论它们是由算法创建的音频源、音频文件还是麦克风提供的。Web Audio API 还为此类音频数据提供了硬件加速的操作和可视化方法,以及将您自己的 JavaScript 操作例程与音频过滤器图挂钩的方法。