個案研究 - Building Technitone.com

Sean Middleditch
Sean Middleditch
技術:網路音訊體驗。

Technitone.com 完美融合了 WebGL、Canvas、Web Sockets、CSS3、JavaScript、Flash 和 Chrome 新推出的 Web Audio API

本文將深入探討生產的各個方面:計畫、伺服器、音效、視覺效果,以及我們用於設計互動性的一些工作流程。大部分區段都包含程式碼片段、示範和下載。文章最後提供下載連結,方便您將所有內容壓縮成一個 ZIP 檔案。

gskinner.com 製作團隊。

音樂

我們不可能都是 gskinner.com 的音訊工程師,但請別出心裁,我們將會擬定一項計畫:

  • 使用者以格狀方式呈現色調」》(靈感來自 AndreToneMatrix)
  • 色調是取樣器、鼓組,甚至是使用者自己的錄音
  • 多人連線在同一格狀空間中同時玩遊戲
  • ...或是進入單人模式自行探索
  • 在邀請工作階段中,使用者就能整理樂團並享受即興演奏

使用者可以利用工具面板,為網路音訊套用各種音訊濾鏡和效果,盡情探索 Web Audio API。

Technitone (gskinner.com)

我們也會:

  • 將使用者組成和效果儲存為資料,並在各個用戶端同步處理
  • 提供不同顏色選項,方便觀眾畫出令人目不轉睛的歌曲
  • 提供藝廊,讓使用者可以聆聽、喜愛,甚至編輯其他人的作品

我們不僅採用眾所周知的網格象徵元素,將這種概念懸掛在 3D 空間中,還增添了光線、紋理和粒子效果,並將其放在靈活的 CSS (或全螢幕) CSS 與 JS 導向的介面中。

公路之旅

設備、效果和格線資料會在用戶端上整合並序列化,然後傳送至我們的自訂 Node.js 後端,以便為多位使用者進行 Socket.io 解析。這項資料會將這份資料傳回用戶端,內含每位玩家所貢獻的內容,然後才分散到相關的 CSS、WebGL 和 WebAudio 層,以便在多使用者播放期間呈現使用者介面、樣本和效果。

在用戶端與通訊端提供 JavaScript 即時通訊,以及在伺服器上的 JavaScript 進行通訊。

技術性伺服器圖表

我們使用節點來處理伺服器的各個層面。這是一個靜態的網路伺服器和我們的通訊端伺服器。Express 實際成果就是我們最終使用的,這是一種完全在節點上建構的網路伺服器。這套架構十分可擴充、具備高度自訂彈性,並能為您處理低階伺服器層面 (就像 Apache 或 Windows Server 一樣)。之後,身為開發人員,您只需專心建構應用程式。

多使用者示範 (好,這只是一張螢幕截圖)

這個範例需要透過 Node 伺服器執行。由於本文並非本文,因此我們附上了示範模式的螢幕擷取畫面,說明您安裝 Node.js、設定網路伺服器,並在本機執行之後的樣子。每當新使用者造訪您的示範版時,系統就會加入新的格狀檢視畫面,並向其他使用者顯示所有人的作業。

Node.js 示範的螢幕截圖

節點十分簡單,使用 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. 呼叫繪圖方法,告知結構定義來啟用著色器、使用資料執行,以及更新畫布。

實際程式碼看起來會像這樣:

設定視角矩陣...

// 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 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 版本視覺彼此堆疊。

場館

除了格線和粒子通道之外,所有其他 UI 元素都是以 HTML / CSS 建構,並在 JavaScript 中建構互動邏輯。

從一開始,我們決定使用者應盡快與格線互動。沒有啟動畫面、不提供操作說明、沒有教學課程,而是「開始」。介面載入後,應該不會有太大影響。

有鑑於此,我們必須仔細思考該如何引導初次接觸的使用者完成互動。我們納入了細微的提示,例如讓 CSS 遊標屬性根據使用者在 WebGL 空間中的滑鼠位置而變更。如果遊標位在格狀檢視畫面中,我們會將遊標切換為手遊遊標 (因為遊標可以加上色調互動)。當滑鼠懸停在網格周圍的空白區域,我們會將其替換成方向交叉遊標 (表示可以旋轉,或是將格線分解為圖層)。

準備加入表演

LESS (CSS 預先處理器) 和 CodeKit (類固醇上的網頁開發) 大幅縮短了將設計檔案轉譯成虛假 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:採用樣式的簡單結構

我們使用絕對定位將每個區域放在正確的位置,並調整背景位置屬性,讓各區域中的背景插圖對齊。這會讓所有區域 (每個區域都使用同一個背景圖片) 看起來都像是單一元素。

.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);
}

如需目前的瀏覽器支援和建議用於 CSS3 轉換作業的建議,請前往 HTML5。

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,請持續關注我們的網誌

錶帶

感謝你的閱讀,我們或許很快就會與你加入