Case study - Creazione di Technitone.com

Sean Middleditch
Sean Middleditch
Technitone: un'esperienza audio sul web.

Technitone.com è una fusione di WebGL, Canvas, Web Sockets, CSS3, JavaScript, Flash e la nuova API Web Audio in Chrome.

Questo articolo toccherà tutti gli aspetti della produzione: il piano, il server, i suoni, le immagini e alcuni dei flussi di lavoro che utilizziamo per progettare contenuti interattivi. La maggior parte delle sezioni contiene snippet di codice, una demo e un download. Alla fine dell'articolo è presente un link per il download che ti consente di scaricarli tutti in un unico file ZIP.

Il team di produzione di gskinner.com.

Il concerto

Non siamo ingegneri del suono su gskinner.com, ma se ci presenti una sfida, troveremo un piano:

  • Gli utenti tracciano i toni su una griglia,"ispirata" a ToneMatrix di Andre
  • I toni sono collegati a strumenti campionati, kit di batteria o persino alle registrazioni degli utenti.
  • Più utenti connessi giocano contemporaneamente sulla stessa griglia
  • …oppure passare alla modalità singola per esplorare in autonomia
  • Le sessioni con invito consentono agli utenti di organizzare un gruppo e fare una jam improvvisata

Offriamo agli utenti la possibilità di esplorare l'API Web Audio tramite un riquadro degli strumenti che applica filtri ed effetti audio ai loro toni.

Technitone di gskinner.com

Inoltre:

  • Memorizzare le composizioni e gli effetti degli utenti come dati e sincronizzarli su tutti i client
  • Fornisci alcune opzioni di colore in modo che possano disegnare canzoni dall'aspetto fantastico.
  • Offri una galleria in cui le persone possano ascoltare, mettere Mi piace o persino modificare i lavori di altre persone

Abbiamo mantenuto la familiare metafora della griglia, l'abbiamo messa a galleggiare nello spazio 3D, abbiamo aggiunto illuminazione, texture ed effetti particellari e l'abbiamo inserita in un'interfaccia flessibile (o a schermo intero) basata su CSS e JS.

Viaggio on the road

I dati di strumenti, effetti e griglie vengono consolidati e serializzati sul client, quindi inviati al nostro backend Node.js personalizzato per la risoluzione per più utenti in stile Socket.io. Questi dati vengono inviati al client con i contributi di ciascun giocatore inclusi, prima di essere distribuiti ai relativi livelli CSS, WebGL e WebAudio incaricati di eseguire il rendering dell'interfaccia utente, dei sample e degli effetti durante la riproduzione multiutente.

La comunicazione in tempo reale con le socket alimenta JavaScript sul client e JavaScript sul server.

Diagramma del server Technitone

Utilizziamo Node per ogni aspetto del server. È un server web statico e il nostro server socket tutto in uno. Alla fine abbiamo utilizzato Express, un server web completo costruito interamente su Node. È estremamente scalabile, altamente personalizzabile e gestisce gli aspetti di basso livello del server per te (proprio come Apache o Windows Server). In questo modo, tu, come sviluppatore, dovrai concentrarti solo sulla creazione della tua applicazione.

Demo multiutente (in realtà è solo uno screenshot)

Questa demo deve essere eseguita su un server Node e, poiché questo articolo non è uno, abbiamo incluso uno screenshot dell'aspetto della demo dopo aver installato Node.js, configurato il server web ed eseguito la demo localmente. Ogni volta che un nuovo utente visita l'installazione di prova, viene aggiunta una nuova griglia e il lavoro di tutti è visibile a tutti.

Screenshot della demo di Node.js

Node è facile. Grazie a una combinazione di Socket.io e richieste POST personalizzate, non abbiamo dovuto creare routine complesse per la sincronizzazione. Socket.io gestisce questa operazione in modo trasparente; il JSON viene trasmesso.

Quanto è facile? Guarda questo.

Con tre righe di JavaScript abbiamo un server web attivo e funzionante con 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/'));

Ancora un po' per collegare socket.io per la comunicazione in tempo reale.

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

});

Ora iniziamo a monitorare le connessioni in entrata dalla pagina 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.
}

…configurare il routing modulare…

/**
 * 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.

…applica un effetto di runtime (convoluzione mediante una risposta all'impulso)…

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

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

…applica un altro effetto di runtime (ritardo)…

/**
 * 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

…e poi renderli udibili.

/**
 * 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!

Il nostro approccio alla riproduzione in Technitone si basa sulla pianificazione. Anziché impostare un intervallo del timer uguale al nostro tempo per elaborare i suoni a ogni battito, abbiamo impostato un intervallo più breve che gestisce e pianifica i suoni in una coda. In questo modo, l'API può eseguire in anteprima il lavoro di risoluzione dei dati audio ed elaborazione di filtri ed effetti prima di assegnare alla CPU il compito di renderli effettivamente udibili. Quando arriva il momento, il sistema ha già tutte le informazioni necessarie per presentare il risultato netto agli altoparlanti.

Nel complesso, era necessario ottimizzare tutto. Quando abbiamo spinto troppo le nostre CPU, i processi sono stati ignorati (pop, click, scratch) per rispettare la tabella di marcia. Ci siamo impegnati molto per fermare tutto se passi a un'altra scheda in Chrome.

Spettacolo di luci

Al centro vediamo la griglia e il tunnel di particelle. Si tratta del livello WebGL di Technitone.

WebGL offre prestazioni notevolmente superiori rispetto alla maggior parte degli altri approcci per il rendering delle immagini sul web, in quanto richiede alla GPU di lavorare in combinazione con il processore. Questo aumento delle prestazioni comporta il costo di uno sviluppo molto più complesso con una curva di apprendimento molto più ripida. Detto questo, se ti appassionano davvero le interazioni sul web e vuoi il minor numero possibile di limitazioni alle prestazioni, WebGL offre una soluzione simile a Flash.

Demo WebGL

I contenuti WebGL vengono visualizzati in una tela (ovvero la tela HTML5) e sono costituiti da questi elementi di base:

  • vertici dell'oggetto (geometria)
  • Matrici di posizione (coordinate 3D)
    • shader (una descrizione dell'aspetto della geometria, collegata direttamente alla GPU)
    • il contesto ("scorciatoie" agli elementi a cui fa riferimento la GPU)
    • buffer (pipeline per il passaggio dei dati di contesto alla GPU)
    • il codice principale (la logica di business specifica per l'interattivo desiderato)
    • il metodo"draw" (attiva gli shader e disegna i pixel sulla tela)

La procedura di base per eseguire il rendering dei contenuti WebGL sullo schermo è la seguente:

  1. Imposta la matrice di prospettiva (regola le impostazioni della fotocamera che osserva lo spazio 3D, definendo il piano dell'immagine).
  2. Imposta la matrice di posizione (dichiara un'origine nelle coordinate 3D rispetto alla quale vengono misurate le posizioni).
  3. Riempi i buffer con dati (posizione del vertice, colore, texture e così via) da passare al contesto tramite gli shader.
  4. Estrai e organizza i dati dai buffer con gli shader e passali alla GPU.
  5. Chiama il metodo draw per indicare al contesto di attivare gli shader, eseguire l'applicazione con i dati e aggiornare la tela.

Ecco come funziona:

Imposta la matrice di prospettiva…

// 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);

…imposta la matrice di posizione…

// 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);

…definire la geometria e l'aspetto…

// 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]);

…riempie i buffer con i dati e li passa al contesto…

// 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);

…e chiama il metodo 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);

In ogni frame, ricordati di cancellare la tela se non vuoi che le visualizzazioni basate su alpha si sovrappongano.

The Venue

Oltre alla griglia e al tunnel di particelle, tutti gli altri elementi dell'interfaccia utente sono stati creati in HTML / CSS e la logica interattiva in JavaScript.

Fin dall'inizio, abbiamo deciso che gli utenti dovevano interagire con la griglia il più rapidamente possibile. Nessuna schermata iniziale, nessuna istruzione, nessun tutorial, solo "Inizia". Se l'interfaccia è caricata, non dovrebbe esserci nulla che la rallenti.

Per questo motivo, abbiamo dovuto esaminare attentamente il modo in cui guidare un utente alle prime esperienze durante le sue interazioni. Abbiamo incluso indicatori sottili, ad esempio la modifica della proprietà cursore CSS in base alla posizione del mouse dell'utente nello spazio WebGL. Se il cursore si trova sopra la griglia, viene visualizzato un cursore a forma di mano (in quanto possono interagire tracciando i toni). Se il cursore si trova nello spazio vuoto intorno alla griglia, lo sostituiamo con un cursore a forma di croce direzionale (per indicare che è possibile ruotare la griglia o espanderla in livelli).

Preparazione per lo spettacolo

LESS (un pre-processore CSS) e CodeKit (sviluppatore web con steroidi) hanno ridotto notevolmente il tempo necessario per tradurre i file di design in HTML/CSS stub. Ci consentono di organizzare, scrivere e ottimizzare il CSS in modo molto più versatile, sfruttando variabili, mix-in (funzioni) e persino la matematica.

Effetti di scena

Utilizzando le transizioni CSS3 e backbone.js abbiamo creato alcuni effetti molto semplici che contribuiscono a dare vita all'applicazione e forniscono agli utenti indicatori visivi che indicano lo strumento in uso.

I colori di Technitone.

Backbone.js ci consente di rilevare gli eventi di modifica del colore e di applicare il nuovo colore agli elementi DOM appropriati. Le transizioni CSS3 accelerate dalla GPU hanno gestito le modifiche dello stile di colore con un impatto minimo o nullo sul rendimento.

La maggior parte delle transizioni di colore negli elementi dell'interfaccia è stata creata con la transizione dei colori di sfondo. Sopra questo colore di sfondo, inseriamo immagini di sfondo con aree strategiche di trasparenza per far risaltare il colore di sfondo.

HTML: le basi

Per la demo erano necessarie tre regioni di colore: due selezionate dall'utente e una terza con colori misti. Per la nostra illustrazione abbiamo creato la struttura DOM più semplice che supporta le transizioni CSS3 e il minor numero di richieste HTTP.

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

CSS: struttura semplice con stile

Abbiamo utilizzato il posizionamento assoluto per posizionare ogni regione nella posizione corretta e abbiamo modificato la proprietà background-position per allineare l'illustrazione di sfondo all'interno di ogni regione. In questo modo, tutte le regioni (ciascuna con la stessa immagine di sfondo) sembrano un unico elemento.

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

Sono state applicate transizioni con accelerazione GPU che ascoltano gli eventi di modifica del colore. Abbiamo aumentato la durata e modificato la transizione su .color-mixed per dare l'impressione che sia stato necessario del tempo per la miscelazione dei colori.

/* 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);
}

Visita HTML5please per informazioni sull'attuale supporto dei browser e sull'utilizzo consigliato per le transizioni CSS3.

JavaScript: come funziona

Assegnare i colori in modo dinamico è semplice. Cerchiamo nel DOM qualsiasi elemento con la nostra classe di colore e impostiamo il colore di sfondo in base alle selezioni di colore dell'utente. Applichiamo il nostro effetto di transizione a qualsiasi elemento del DOM aggiungendo una classe. In questo modo si crea un'architettura leggera, flessibile e scalabile.

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

Una volta selezionati i colori primario e secondario, calcoliamo il valore del colore misto e assegniamo il valore risultante all'elemento DOM appropriato.

// 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+')';
}

Illustrazione per l'architettura HTML/CSS: dare personalità a tre caselle con variazioni di colore

Il nostro obiettivo era creare un effetto di illuminazione divertente e realistico che mantenesse la sua integrità quando i colori a contrasto venivano posizionati in regioni di colore adiacenti.

Un'immagine PNG a 24 bit consente di visualizzare il colore di sfondo dei nostri elementi HTML attraverso le aree trasparenti dell'immagine.

Immagini trasparenti

Le caselle colorate creano bordi netti dove si incontrano colori diversi. Questo ostacola gli effetti di illuminazione realistici ed è stata una delle maggiori sfide durante la progettazione dell'illustrazione.

Regioni di colore

La soluzione è stata progettare l'illustrazione in modo che i bordi delle regioni di colore non siano mai visibili attraverso le aree trasparenti.

Bordi della regione di colore

La pianificazione della compilazione è stata fondamentale. Una breve sessione di pianificazione tra designer, sviluppatore e illustratore ha aiutato il team a capire come doveva essere realizzato il tutto in modo che funzionasse insieme una volta assemblato.

Dai un'occhiata al file di Photoshop come esempio di come la denominazione dei livelli può comunicare informazioni sulla costruzione del CSS.

Bordi della regione di colore

Encore

Per gli utenti che non hanno Chrome, ci siamo prefissati di distillare l'essenza dell'applicazione in un'unica immagine statica. Il nodo della griglia è diventato l'elemento principale, i riquadri di sfondo alludono allo scopo dell'applicazione e la prospettiva presente nel riflesso suggerisce l'ambiente 3D immersivo della griglia.

Colora i bordi della regione.

Se vuoi saperne di più su Technitone, continua a seguire il nostro blog.

Il cinturino

Grazie per aver letto. Magari ci faremo una jam con te a breve.