Technitone.com 融合了 WebGL、Canvas、Web Sockets、CSS3、Javascript、Flash 和 Chrome 中的新 Web Audio API。
本文將介紹製作過程的各個面向:企劃、伺服器、音效、視覺效果,以及我們用於互動式設計的部分工作流程。大部分的章節都包含程式碼片段、示範和下載項目。文章結尾處有下載連結,您可以將所有檔案下載為一個 ZIP 檔案。
工作
我們絕非 gskinner.com 的音訊工程師,但如果有挑戰,我們會想辦法解決:
- 使用者可在格線上繪製色調,這項功能受到 Andre 的 ToneMatrix 啟發
- 音色會連結到取樣樂器、鼓組,甚至是使用者自己的錄音
- 多位已連線的使用者同時在同一個格子上玩遊戲
- …或進入單人模式自行探索
- 邀請式工作階段可讓使用者組成樂團,並即興演出
我們提供工具列,讓使用者能夠在音效上套用音訊篩選器和效果,探索 Web Audio API。
我們也:
- 將使用者的合成效果和效果儲存為資料,並在各用戶端間同步處理
- 提供一些顏色選項,讓他們可以繪製酷炫的歌曲
- 提供相片庫,讓使用者可以聆聽、喜歡甚至編輯他人的作品
我們沿用熟悉的格線比喻,將格線漂浮在 3D 空間中,並加入一些光照、紋理和粒子效果,並將格線置於 CSS 和 JS 驅動的彈性 (或全螢幕) 介面中。
公路之旅
在用戶端上會整合及序列化儀器、效果和格狀資料,然後傳送至自訂 Node.js 後端,以便為多位使用者解決問題,就像 Socket.io 一樣。這項資料會連同每個播放器的貢獻內容傳回用戶端,然後分散至相對應的 CSS、WebGL 和 WebAudio 層,負責在多使用者播放期間算繪 UI、樣本和效果。
透過 Socket 進行即時通訊,可在用戶端和伺服器上提供 JavaScript 動態資料。
我們在伺服器的各個層面都使用 Node。它是靜態網路伺服器和我們的 Socket 伺服器的整合式服務。我們最後使用的是 Express,這是一個完全以 Node 建構的完整網頁伺服器。它具有極佳的擴充性和高度自訂性,並可為您處理低階伺服器方面的事宜 (就像 Apache 或 Windows Server 一樣)。這樣一來,開發人員只需專注於建構應用程式。
多用戶示範 (其實只是螢幕截圖)
這個示範需要透過 Node 伺服器執行,但由於本文章並非以此方式運作,我們已附上相關螢幕截圖,讓您瞭解安裝 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 內容算繪至螢幕的基本流程如下:
- 設定透視矩陣 (調整攝影機的設定,以便窺探 3D 空間,定義影像平面)。
- 設定位置矩陣 (在 3D 座標中宣告原點,以便測量位置)。
- 將資料 (頂點位置、顏色、紋理…) 填入緩衝區,以便透過著色器傳遞至內容。
- 使用著色器從緩衝區擷取及整理資料,然後將資料傳遞至 GPU。
- 呼叫 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 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 建構互動邏輯。
從一開始,我們就決定使用者應盡可能快速地與格線互動。沒有啟動畫面、沒有操作說明,也沒有教學課程,只需要「Go」如果介面已載入,則不應有任何因素造成延遲。
因此,我們必須仔細思考如何引導初次使用者進行互動。我們加入了細微的提示,例如根據使用者在 WebGL 空間中的滑鼠位置變更 CSS 游標屬性。如果游標位於格線上,我們會將其切換為手勢游標 (因為手勢游標可透過繪製音調進行互動)。如果游標懸停在格線周圍的空白區域,我們會將其換成方向十字游標 (表示可旋轉,或將格線分解為多個圖層)。
準備表演
LESS (CSS 前置處理器) 和 CodeKit (網路開發工具) 可大幅縮短將設計檔案轉譯為 HTML/CSS 的時間。這些功能可讓我們以更靈活的方式整理、編寫及最佳化 CSS,並善用變數、混合函式,甚至是數學!
舞台效果
我們使用 CSS3 轉場效果和 backbone.js 製作了一些非常簡單的效果,讓應用程式更生動有趣,並為使用者提供視覺提示,指出他們正在使用哪種樂器。
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:實際運用
動態指派顏色很簡單。我們會在 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,請密切留意我們的部落格。
錶帶
感謝你閱讀本文,我們或許很快就會與你一起創作音樂!