案例研究 - 构建 Technitone.com

Sean Middleditch
Sean Middleditch
Technitone - 一种 Web 音频体验。

Technitone.com 融合了 WebGL、Canvas、Web Sockets、CSS3、JavaScript、Flash 以及 Chrome 中的新 Web Audio API

本文将简要介绍制作的各个方面:计划、服务器、音效、视觉效果,以及我们在设计互动内容时所利用的一些工作流。大多数部分都包含代码段、演示和下载内容。文章末尾提供了一个下载链接,您可以通过该链接将所有资源打包为一个 ZIP 文件。

gskinner.com 制作团队。

演出

gskinner.com 的团队成员绝不是音频工程师,但如果您有问题,我们会想办法解决:

  • 用户在网格上绘制色调,这受到了 AndreToneMatrix 的“启发”
  • 音色会连接到采样乐器、鼓组,甚至用户的自录音
  • 多位已连接的用户同时在同一网格上玩游戏
  • …或进入单人模式自行探索
  • 邀请式会话可让用户组建乐队并即兴演奏

我们提供了一个工具面板,可让用户对音调应用音频过滤器和效果,从而探索 Web Audio API。

Technitone by gskinner.com

我们还会:

  • 将用户的构图和特效存储为数据,并在客户端之间同步
  • 提供一些颜色选项,以便他们绘制看起来很酷的歌曲
  • 提供一个内容库,让用户可以收听、赞过或甚至编辑他人的作品

我们坚持使用熟悉的网格比喻,将其浮动在 3D 空间中,添加了一些光照、纹理和粒子效果,并将其容纳在由 CSS 和 JS 驱动的灵活(或全屏)界面中。

自驾游

乐器、效果和网格数据会在客户端上进行整合和序列化,然后发送到我们的自定义 Node.js 后端,以便通过 Socket.io 为多位用户解析。这些数据会包含每个玩家的贡献,并发送回客户端,然后分散到负责在多用户播放期间渲染界面、选段和效果的相应 CSS、WebGL 和 WebAudio 层。

通过套接字进行实时通信,可将 JavaScript 提取到客户端和服务器上的 JavaScript。

Technitone 服务器示意图

我们在服务器的各个方面都使用 Node。它集静态网站服务器和套接字服务器于一体。我们最终使用的是 Express,它是一个完全基于 Node 构建的完整 Web 服务器。它具有超强的扩展性和高度的可定制性,并会为您处理低级服务器方面的问题(就像 Apache 或 Windows Server 一样)。这样一来,作为开发者,您只需专注于构建应用即可。

多用户演示(好吧,这实际上只是一张屏幕截图)

此演示需要在 Node 服务器上运行,但本文不会介绍如何在 Node 服务器上运行演示,而是提供了一张屏幕截图,展示了在您安装 Node.js、配置 Web 服务器并在本地运行该服务器后,演示的显示效果。每当有新用户访问您的演示版安装时,系统都会添加一个新的网格,并且每个人的设计都会对其他人可见。

Node.js 演示的屏幕截图

节点很简单。通过结合使用 Socket.io 和自定义 POST 请求,我们无需构建复杂的同步例程。Socket.io 会透明地处理此问题;JSON 会传递给其他人。

有多简单?看看这个

只需 3 行 JavaScript 代码,我们就可以启动并运行一个使用 Express 的 Web 服务器。

//Tell  our Javascript file we want to use express.
var express = require('express');

//Create our web-server
var server = express.createServer();

//Tell express where to look for our static files.
server.use(express.static(__dirname + '/static/'));

再添加一些代码,将 socket.io 关联起来以实现实时通信。

var io = require('socket.io').listen(server);
//Start listening for socket commands
io.sockets.on('connection', function (socket) {
    //User is connected, start listening for commands.
    socket.on('someEventFromClient', handleEvent);

});

现在,我们只需开始监听 HTML 页面中的传入连接即可。

<!-- Socket-io will serve it-self when requested from this url. -->
<script type="text/javascript" src="/socket.io/socket.io.js"></script>

 <!-- Create our socket and connect to the server -->
 var sock = io.connect('http://localhost:8888');
 sock.on("connect", handleConnect);

 function handleConnect() {
    //Send a event to the server.
    sock.emit('someEventFromClient', 'someData');
 }
 ```

## Sound check

A big unknown was the effort entailed with using the Web Audio API. Our initial findings confirmed that [Digital Signal Processing](http://en.wikipedia.org/wiki/Digital_Signal_Processing) (DSP) is very complex, and we were likely in way over our heads. Second realization: [Chris Rogers](http://chromium.googlecode.com/svn/trunk/samples/audio/index.html) has already done the heavy lifting in the API.
Technitone isn't using any really complex math or audioholicism; this functionality is easily accessible to interested developers. We really just needed to brush up on some terminology and [read the docs](https://dvcs.w3.org/hg/audio/raw-file/tip/webaudio/specification.html). Our advice? Don't skim them. Read them. Start at the top and end at the bottom. They are peppered with diagrams and photos, and it's really cool stuff.

If this is the first you've heard of the Web Audio API, or don't know what it can do, hit up Chris Rogers' [demos](http://chromium.googlecode.com/svn/trunk/samples/audio/index.html). Looking for inspiration? You'll definitely find it there.

### Web Audio API Demo

Load in a sample (sound file)…

```js
/**
 * The XMLHttpRequest allows you to get the load
 * progress of your file download and has a responseType
 * of "arraybuffer" that the Web Audio API uses to
 * create its own AudioBufferNode.
 * Note: the 'true' parameter of request.open makes the
 * request asynchronous - this is required!
 */
var request = new XMLHttpRequest();
request.open("GET", "mySample.mp3", true);
request.responseType = "arraybuffer";
request.onprogress = onRequestProgress; // Progress callback.
request.onload = onRequestLoad; // Complete callback.
request.onerror = onRequestError; // Error callback.
request.onabort = onRequestError; // Abort callback.
request.send();

// Use this context to create nodes, route everything together, etc.
var context = new webkitAudioContext();

// Feed this AudioBuffer into your AudioBufferSourceNode:
var audioBuffer = null;

function onRequestProgress (event) {
    var progress = event.loaded / event.total;
}

function onRequestLoad (event) {
    // The 'true' parameter specifies if you want to mix the sample to mono.
    audioBuffer = context.createBuffer(request.response, true);
}

function onRequestError (event) {
    // An error occurred when trying to load the sound file.
}

…设置模块化路由…

/**
 * Generally you'll want to set up your routing like this:
 * AudioBufferSourceNode > [effect nodes] > CompressorNode > AudioContext.destination
 * Note: nodes are designed to be able to connect to multiple nodes.
 */

// The DynamicsCompressorNode makes the loud parts
// of the sound quieter and quiet parts louder.
var compressorNode = context.createDynamicsCompressor();
compressorNode.connect(context.destination);

// [other effect nodes]

// Create and route the AudioBufferSourceNode when you want to play the sample.

…应用运行时效果(使用脉冲响应进行卷积)…

/**
 * Your routing now looks like this:
 * AudioBufferSourceNode > ConvolverNode > CompressorNode > AudioContext.destination
 */

var convolverNode = context.createConvolver();
convolverNode.connect(compressorNode);
convolverNode.buffer = impulseResponseAudioBuffer;

…应用另一个运行时效果(延迟)…

/**
 * The delay effect needs some special routing.
 * Unlike most effects, this one takes the sound data out
 * of the flow, reinserts it after a specified time (while
 * looping it back into itself for another iteration).
 * You should add an AudioGainNode to quieten the
 * delayed sound...just so things don't get crazy :)
 *
 * Your routing now looks like this:
 * AudioBufferSourceNode -> ConvolverNode > CompressorNode > AudioContext.destination
 *                       |  ^
 *                       |  |___________________________
 *                       |  v                          |
 *                       -> DelayNode > AudioGainNode _|
 */

var delayGainNode = context.createGainNode();
delayGainNode.gain.value = 0.7; // Quieten the feedback a bit.
delayGainNode.connect(convolverNode);

var delayNode = context.createDelayNode();
delayNode.delayTime = 0.5; // Re-sound every 0.5 seconds.
delayNode.connect(delayGainNode);

delayGainNode.connect(delayNode); // make the loop

…然后让其发出声音。

/**
 * Once your routing is set up properly, playing a sound
 * is easy-shmeezy. All you need to do is create an
 * AudioSourceBufferNode, route it, and tell it what time
 * (in seconds relative to the currentTime attribute of
 * the AudioContext) it needs to play the sound.
 *
 * 0 == now!
 * 1 == one second from now.
 * etc...
 */

var sourceNode = context.createBufferSource();
sourceNode.connect(convolverNode);
sourceNode.connect(delayNode);
sourceNode.buffer = audioBuffer;
sourceNode.noteOn(0); // play now!

我们在 Technitone 中播放内容的方法完全取决于时间安排。我们没有设置等于节奏的计时器间隔,以便在每个节拍处理声音,而是设置了较小的间隔,以便管理和调度队列中的声音。这样一来,API 就可以先解析音频数据并处理滤镜和效果,然后再让 CPU 执行实际的播放任务。当该节拍最终到来时,它已经拥有向音箱呈现最终结果所需的所有信息。

总体而言,所有内容都需要优化。如果我们过度使用 CPU,系统会跳过一些进程(弹出、点击、划痕)以跟上时间表;如果您在 Chrome 中跳转到其他标签页,我们会努力停止所有这些疯狂的操作。

灯光秀

最醒目的是网格和粒子隧道。这是 Technitone 的 WebGL 层。

WebGL 通过让 GPU 与处理器协同工作,在 Web 上呈现视觉效果方面比大多数其他方法的性能都高出很多。这种性能提升的代价是开发工作变得更加复杂,学习曲线也更陡峭。不过,如果您对 Web 互动非常热衷,并且希望尽可能减少性能限制,WebGL 提供了一个与 Flash 相当的解决方案。

WebGL 演示

WebGL 内容会渲染到画布(字面意思是 HTML5 画布),并且由以下核心构建块组成:

  • 对象顶点(几何图形)
  • 位置矩阵(3D 坐标)
    • 着色器(几何图形外观的描述,直接与 GPU 相关联)
    • 上下文(对 GPU 引用的元素的“快捷方式”)
    • 缓冲区(用于将上下文数据传递给 GPU 的流水线)
    • 主要代码(特定于所需 Interactive 的业务逻辑)
    • “draw”方法(激活着色器并将像素绘制到画布)

将 WebGL 内容渲染到屏幕的基本流程如下所示:

  1. 设置透视矩阵(调整用于窥视 3D 空间的相机的设置,定义平面)。
  2. 设置位置矩阵(在 3D 坐标中声明一个用于测量位置的坐标原点)。
  3. 使用数据(顶点位置、颜色、纹理等)填充缓冲区,以便通过着色器传递到上下文。
  4. 使用着色器从缓冲区中提取和整理数据,并将其传递到 GPU。
  5. 调用 draw 方法,告知上下文激活着色器、使用数据运行并更新画布。

实际运作方式如下所示:

设置透视矩阵…

// Aspect ratio (usually based off the viewport,
// as it can differ from the canvas dimensions).
var aspectRatio = canvas.width / canvas.height;

// Set up the camera view with this matrix.
mat4.perspective(45, aspectRatio, 0.1, 1000.0, pMatrix);

// Adds the camera to the shader. [context = canvas.context]
// This will give it a point to start rendering from.
context.uniformMatrix4fv(shader.pMatrixUniform, 0, pMatrix);

…设置位置矩阵…

// This resets the mvMatrix. This will create the origin in world space.
mat4.identity(mvMatrix);

// The mvMatrix will be moved 20 units away from the camera (z-axis).
mat4.translate(mvMatrix, [0,0,-20]);

// Sets the mvMatrix in the shader like we did with the camera matrix.
context.uniformMatrix4fv(shader.mvMatrixUniform, 0, mvMatrix);

…定义一些几何图形和外观…

// Creates a square with a gradient going from top to bottom.
// The first 3 values are the XYZ position; the last 4 are RGBA.
this.vertices = new Float32Array(28);
this.vertices.set([-2,-2, 0,    0.0, 0.0, 0.7, 1.0,
                   -2, 2, 0,    0.0, 0.4, 0.9, 1.0,
                    2, 2, 0,    0.0, 0.4, 0.9, 1.0,
                    2,-2, 0,    0.0, 0.0, 0.7, 1.0
                  ]);

// Set the order of which the vertices are drawn. Repeating values allows you
// to draw to the same vertex again, saving buffer space and connecting shapes.
this.indices = new Uint16Array(6);
this.indices.set([0,1,2, 0,2,3]);

…将数据填充到缓冲区,并将其传递给上下文…

// Create a new storage space for the buffer and assign the data in.
context.bindBuffer(context.ARRAY_BUFFER, context.createBuffer());
context.bufferData(context.ARRAY_BUFFER, this.vertices, context.STATIC_DRAW);

// Separate the buffer data into its respective attributes per vertex.
context.vertexAttribPointer(shader.vertexPositionAttribute,3,context.FLOAT,0,28,0);
context.vertexAttribPointer(shader.vertexColorAttribute,4,context.FLOAT,0,28,12);

// Create element array buffer for the index order.
context.bindBuffer(context.ELEMENT_ARRAY_BUFFER, context.createBuffer());
context.bufferData(context.ELEMENT_ARRAY_BUFFER, this.indices, context.STATIC_DRAW);

…并调用 draw 方法

// Draw the triangles based off the order: [0,1,2, 0,2,3].
// Draws two triangles with two shared points (a square).
context.drawElements(context.TRIANGLES, 6, context.UNSIGNED_SHORT, 0);

如果您不希望基于 Alpha 的视觉效果堆叠在一起,请记得在每个帧中清除画布。

The Venue

除了网格和粒子隧道之外,所有其他界面元素都是使用 HTML / CSS 构建的,互动逻辑则是使用 JavaScript 构建的。

从一开始,我们就决定用户应能尽快与网格进行互动。没有启动画面、没有说明、没有教程,只有“开始”。如果接口已加载,则不应有任何因素会导致其运行缓慢。

这要求我们仔细研究如何引导新用户完成互动。我们添加了一些细微的提示,例如让 CSS 光标属性根据用户在 WebGL 空间中的鼠标位置而变化。如果光标位于网格上,我们会将其切换为手形光标(因为用户可以通过绘制音调进行互动)。如果光标悬停在网格周围的空白处,我们会将其替换为方向性十字光标(以指示用户可以旋转或将网格展开为图层)。

为展示做好准备

LESS(CSS 预处理器)和 CodeKit(Web 开发神器)大大缩短了将设计文件转换为 HTML/CSS 的耗时。借助这些功能,我们可以利用变量、混入项(函数)甚至数学运算,以更灵活的方式整理、编写和优化 CSS!

舞台效果

我们使用 CSS3 转场效果backbone.js 创建了一些非常简单的效果,这些效果有助于让应用更加生动,并为用户提供视觉提示,指明他们正在使用哪种乐器。

Technitone 的颜色。

借助 Backbone.js,我们可以捕获颜色更改事件,并将新颜色应用于适当的 DOM 元素。GPU 加速的 CSS3 转场效果处理了颜色样式更改,对性能的影响微乎其微。

界面元素上的大多数颜色过渡都是通过转换背景颜色创建的。在这种背景颜色之上,我们会放置具有战略性透明区域的背景图片,以让背景颜色透过来。

HTML:基础知识

我们需要为演示创建三个颜色区域:两个由用户选择的颜色区域,以及第三个混合颜色区域。为了说明问题,我们构建了我们能想到的最简单的 DOM 结构,该结构支持 CSS3 转换,并且发出的 HTTP 请求数量最少。

<!-- Basic HTML Setup -->
<div class="illo color-mixed">
  <div class="illo color-primary"></div>
  <div class="illo color-secondary"></div>
</div>

CSS:带有样式的简单结构

我们使用绝对定位将每个区域放置在正确的位置,并调整了 background-position 属性,以便在每个区域内对齐背景插图。这样一来,所有区域(每个区域都具有相同的背景图片)看起来就像一个元素。

.illo {
  background: url('../img/illo.png') no-repeat;
  top:        0;
  cursor:     pointer;
}
  .illo.color-primary, .illo.color-secondary {
    position: absolute;
    height:   100%;
  }
  .illo.color-primary {
    width:                350px;
    left:                 0;
    background-position:  top left;
  }
  .illo.color-secondary {
    width:                355px;
    right:                0;
    background-position:  top right;
  }

应用了监听颜色更改事件的 GPU 加速转换。我们延长了时长并修改了 .color-mixed 的缓动,以营造颜色需要时间才能混合的感觉。

/* Apply Transitions To Backgrounds */
.color-primary, .color-secondary {
  -webkit-transition: background .5s linear;
  -moz-transition:    background .5s linear;
  -ms-transition:     background .5s linear;
  -o-transition:      background .5s linear;
}

.color-mixed {
  position:           relative;
  width:              750px;
  height:             600px;
  -webkit-transition: background 1.5s cubic-bezier(.78,0,.53,1);
  -moz-transition:    background 1.5s cubic-bezier(.78,0,.53,1);
  -ms-transition:     background 1.5s cubic-bezier(.78,0,.53,1);
  -o-transition:      background 1.5s cubic-bezier(.78,0,.53,1);
}

请访问 HTML5please,了解当前浏览器对 CSS3 过渡的支持情况以及建议的使用方式。

JavaScript:运用 JavaScript

动态分配颜色非常简单。我们会在 DOM 中搜索具有颜色类的任何元素,并根据用户选择的颜色设置背景颜色。我们通过添加类将转场效果应用于 DOM 中的任何元素。这样便可打造轻量、灵活且可伸缩的架构。

function createPotion() {

    var primaryColor = $('.picker.color-primary > li.selected').css('background-color');
    var secondaryColor = $('.picker.color-secondary > li.selected').css('background-color');
    console.log(primaryColor, secondaryColor);
    $('.illo.color-primary').css('background-color', primaryColor);
    $('.illo.color-secondary').css('background-color', secondaryColor);

    var mixedColor = mixColors (
            parseColor(primaryColor),
            parseColor(secondaryColor)
    );

    $('.color-mixed').css('background-color', mixedColor);
}

选择主色和辅色后,我们会计算它们的混合颜色值,并将计算结果分配给相应的 DOM 元素。

// take our rgb(x,x,x) value and return an array of numeric values
function parseColor(value) {
    return (
            (value = value.match(/(\d+),\s*(\d+),\s*(\d+)/)))
            ? [value[1], value[2], value[3]]
            : [0,0,0];
}

// blend two rgb arrays into a single value
function mixColors(primary, secondary) {

    var r = Math.round( (primary[0] * .5) + (secondary[0] * .5) );
    var g = Math.round( (primary[1] * .5) + (secondary[1] * .5) );
    var b = Math.round( (primary[2] * .5) + (secondary[2] * .5) );

    return 'rgb('+r+', '+g+', '+b+')';
}

为 HTML/CSS 架构创建插图:为三个颜色变化框赋予个性

我们的目标是打造一种有趣且逼真的照明效果,使其在相邻的颜色区域中放置对比色时仍能保持完整性。

24 位 PNG 允许 HTML 元素的背景颜色透过图片的透明区域显示。

图片透明度

彩色方框会在不同颜色相遇处形成硬边。这会影响逼真的光效,是设计插图时遇到的一大挑战。

颜色区域

解决方案是,在设计插图时,绝不允许颜色区域的边缘透过透明区域显示。

颜色区域边缘

对此,我们必须做好构建规划。设计师、开发者和插画家之间的快速规划会议帮助团队了解如何构建所有内容,以便在组装时能够协同工作。

请查看 Photoshop 文件,了解如何通过图层命名传达 CSS 构建方面的信息。

颜色区域边缘

Encore

对于未安装 Chrome 的用户,我们的目标是将应用的精髓提炼到单张静态图片中。网格节点成为了主角,背景图块暗示了应用的用途,而反射中的透视图暗示了网格的沉浸式 3D 环境。

为区域边缘着色。

如果您有兴趣详细了解 Technitone,请持续关注我们的博客

乐队

感谢您的阅读,也许我们很快就能与您一起创作音乐了!