案例研究 - 构建 Technitone.com

Sean Middleditch
Sean Middleditch
Technitone - 一种网络音频体验。

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

本文将探讨制作的每个方面:方案、服务器、声音、视觉效果,以及我们用于设计可交互性设计的部分工作流程。大多数部分包含代码段、演示和下载内容。文章末尾有一个下载链接,通过下载链接,您可以将所有内容下载为一个 zip 文件。

gskinner.com 制作团队。

演出

我们不是 gskinner.com 的音频工程师,但可以通过挑战来诱使我们,我们会制定一个方案:

  • 用户受 Andre 的 ToneMatrix“启发”,在网格上绘制色调
  • 可将音调连接到采样乐器、架子鼓,甚至是用户自己的录音
  • 多个已连接的用户可在同一个网格上同时玩游戏
  • ...还能进入独自模式,独自探索
  • 邀请会话可让用户组织一个乐队并进行即兴即兴演奏

我们为用户提供了探索 Web Audio API 的机会,为此我们提供了一个工具面板,该面板将音频滤镜和音效应用于用户的音调。

由 gskinner.com 推出的 Technitone

我们还:

  • 以数据形式存储用户的作品和特效,并在不同客户端之间同步这些数据
  • 提供一些颜色选项,以便他们可以创作外观酷炫的歌曲
  • 提供图库,以便用户聆听、喜爱甚至编辑他人的作品

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

公路旅行

乐器、音效和网格数据在客户端上进行整合和序列化,然后发送至我们的自定义 Node.js 后端,供多个用户使用,即 Socket.io。系统会将此类数据发送回客户端(其中包含每个玩家的贡献),然后再将其分散到负责在多用户播放期间呈现界面、样本和效果的相关 CSS、WebGL 和 WebAudio 层。

与套接字的实时通信会将 JavaScript 馈送到客户端和服务器上的 JavaScript。

Technitone 服务器示意图

我们对服务器的方方面面都使用了 Node。该服务器兼具静态网络服务器和我们的套接字服务器的功能。Express 是我们最终使用的工具,它是完全基于 Node 构建的完整 Web 服务器。它具有极强的可扩展性和高度的可定制性,并能为您处理低级别的服务器方面(就像 Apache 或 Windows Server 一样)。然后,作为开发者,您只需专注于构建应用。

多用户演示(好,只是一张屏幕截图)

此演示需要在 Node 服务器上运行,由于本文不是,我们添加了屏幕截图,展示了您安装 Node.js、配置 Web 服务器并在本地运行后演示的效果。每当有新用户访问您的演示版安装时,系统都会添加新的网格,并且每个人都能看到彼此的作品。

Node.js 演示屏幕截图

Node 非常简单。我们结合使用 Socket.io 和自定义 POST 请求,不必构建复杂的同步例程。Socket.io 会对此进行透明处理;系统会传递 JSON

有多容易?看看这个

只需 3 行 JavaScript,我们就能有一个网络服务器,使其通过 Express 启动并运行。

//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 和处理器,其性能远远高于其他大多数在网络上呈现视觉效果的方法。性能上的提升要求以更陡峭的学习曲线进行更为复杂的开发。也就是说,如果您真正热衷于网络互动,并且希望尽可能减少性能限制,WebGL 可以提供与 Flash 相当的解决方案。

WebGL 演示

WebGL 内容会渲染到画布(即 HTML5 画布)上,并由以下核心构建块组成:

  • 对象顶点(几何图形)
  • 位置矩阵(3D 坐标)
    • 着色器(描述几何外观,直接链接到 GPU)
    • 环境(GPU 引用元素的“快捷方式”)
    • 缓冲区(用于将上下文数据传递到 GPU 的管道)
    • 主代码(特定于所需互动的业务逻辑)
    • “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(使用类固醇进行网页开发)确实减少了将设计文件转换为存根 HTML/CSS 所需的时间。有了这些 API,我们就能够以更加灵活的方式组织、编写和优化 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:带样式的简单结构

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

.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:使其发挥作用

动态分配颜色很简单。我们会在 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 元素的背景颜色透过图片的透明区域显示。

图片透明度

当不同颜色相遇时,彩色框的边缘会比较硬。这种做法妨碍了真实的灯光效果,也是设计插图时面临的一大挑战。

颜色区域

解决方法是,在设计插图时始终不允许颜色区域的边缘透过透明区域。

颜色区域边缘

构建规划至关重要。设计人员、开发者和插画师进行一次简短的规划会议,帮助团队了解了所有内容的构建方式,从而使其在组合后能够协同工作。

层命名如何传递有关 CSS 构建的信息,以 Photoshop 文件为例。

颜色区域边缘

Encore

对于没有使用 Chrome 浏览器的用户,我们设定了一个目标:将应用精髓浓缩成单张静态图片。网格节点成了主角,背景图块暗指应用的用途,反射中呈现的视角暗示了网格的沉浸式 3D 环境。

颜色区域边缘。

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

乐队

感谢您的阅读,可能我们很快会和您一起玩