Estudo de caso: criação do Technitone.com

Sean Middleditch
Sean Middleditch
Technitone: uma experiência de áudio na Web.

O Technitone.com é uma fusão de WebGL, Canvas, Web Sockets, CSS3, JavaScript, Flash e a nova API de áudio da Web no Chrome.

Este artigo abordará todos os aspectos da produção: o plano, o servidor, os sons, os recursos visuais e alguns dos fluxos de trabalho que aproveitamos para projetar para interação. A maioria das seções contém snippets de código, uma demonstração e um download. Ao final do artigo, há um link para download onde você pode obter todos eles como um único arquivo zip.

Equipe de produção do gskinner.com.

O trabalho

Não somos, de forma alguma, engenheiros de áudio na gskinner.com, mas tente nos desafiar para elaborar um plano:

  • Os usuários desenham tons em uma grade,"inspirados" na ToneMatrix de Andre
  • Os tons são conectados a amostras de instrumentos, baterias ou até mesmo gravações dos próprios usuários
  • Vários usuários conectados jogam na mesma grade simultaneamente
  • ... ou entrar no modo solo para explorar por conta própria
  • As sessões para convidados permitem que os usuários organizem uma banda e tenham uma jam espontânea

Oferecemos aos usuários uma oportunidade de explorar a API de áudio da Web por meio de um painel de ferramentas que aplica filtros de áudio e efeitos em seus tons.

Technitone da gskinner.com

Nós também:

  • Armazene as composições e efeitos dos usuários como dados e os sincronize entre os clientes
  • Forneça algumas opções de cores para que eles possam desenhar músicas com visual bacana
  • Ofereça uma galeria para que as pessoas possam ouvir, gostar ou até mesmo editar o trabalho de outras pessoas

Mantemos a conhecida metáfora da grade, colocamos ela no espaço 3D, adicionamos alguns efeitos de iluminação, textura e partículas e a hospedamos em uma interface flexível (ou em tela cheia) baseada em CSS e JS.

Viagem de carro

Os dados de instrumentos, efeitos e grades são consolidados e serializados no cliente e depois enviados ao nosso back-end Node.js personalizado para resolver vários usuários ao Socket.io. Esses dados são enviados de volta ao cliente com as contribuições de cada jogador incluídas, antes de serem distribuídos para as camadas relativas de CSS, WebGL e WebAudio responsáveis pela renderização da interface, amostras e efeitos durante a reprodução de vários usuários.

Comunicação em tempo real com JavaScript de feeds de soquetes no cliente e JavaScript no servidor.

Diagrama do servidor do Technitone

Usamos o Node em todos os aspectos do servidor. Trata-se de um servidor da Web estático e nosso servidor de soquetes reunidos em um só. Estamos usando o Express (em inglês), um servidor da Web completo criado inteiramente em Node. É super escalonável, altamente personalizável e lida com os aspectos de baixo nível do servidor (assim como o Apache ou o Windows Server fariam). Assim você, como desenvolvedor, só precisa se concentrar em criar o aplicativo.

Demonstração para vários usuários (na verdade, é apenas uma captura de tela)

A demonstração precisa ser executada em um servidor Node. Como este artigo não é um, incluímos uma captura de tela da aparência da demonstração depois que você instala o Node.js, configura o servidor da Web e o executa localmente. Sempre que um novo usuário acessar sua instalação de demonstração, uma nova grade será adicionada, e o trabalho de todos ficará visível uns para os outros.

Captura de tela da demonstração do Node.js

O Node é fácil. Usando uma combinação de Socket.io e solicitações POST personalizadas, não precisávamos criar rotinas complexas para sincronização. O Socket.io faz isso de forma transparente, e o JSON é transmitido.

Quão fácil? Veja isto.

Com três linhas de JavaScript, temos um servidor da Web funcionando com o 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/'));

Mais alguns para vincular o socket.io à comunicação em tempo real.

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

});

Agora é só começar a ouvir as conexões de entrada da página 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.
}

...configurar roteamento modular...

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

...aplicar um efeito de tempo de execução (convolução usando uma resposta ao impulso)...

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

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

...aplicar outro efeito de tempo de execução (atraso)...

/**
 * 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 torná-lo audível.

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

Nossa abordagem de reprodução no Technitone é programação. Em vez de definir um intervalo do timer igual ao nosso ritmo para processar sons a cada batida, configuramos um intervalo menor que gerencia e programa sons em uma fila. Isso permite que a API faça o trabalho inicial de resolver dados de áudio e processar filtros e efeitos antes de encarregar a CPU de torná-los audíveis. Quando essa batida finalmente chega, ele já tem todas as informações necessárias para apresentar o resultado final aos alto-falantes.

No geral, tudo precisava ser otimizado. Quando exigíamos muito da nossa CPU, os processos eram pulados (abrir, clicar, riscar) para cumprir o cronograma. Fazemos um esforço sério para interromper toda a loucura se você passar para outra guia no Chrome.

Show de luzes

Em frente e no centro está nosso túnel de grade e partícula. Essa é a camada WebGL do Technitone.

O WebGL oferece desempenho consideravelmente superior à maioria dos outros métodos de renderização de elementos visuais na Web, encarregando a GPU de trabalhar em conjunto com o processador. Esse ganho de desempenho vem com o custo de um desenvolvimento significativamente maior, com uma curva de aprendizado muito mais acentuada. Dito isso, se você é apaixonado pela interatividade na Web e quer o menor número possível de restrições de desempenho, o WebGL oferece uma solução comparável ao Flash.

Demonstração do WebGL

O conteúdo WebGL é renderizado em uma tela (literalmente, HTML5 Canvas) e é composto pelos seguintes elementos básicos:

  • vértices de objetos (geometria)
  • matrizes de posição (coordenadas 3D)
    • sombreadores (uma descrição da aparência geométrica, vinculados diretamente à GPU)
    • o contexto ("atalhos" para os elementos aos quais a GPU faz referência)
    • buffers (pipelines para passar dados de contexto para a GPU)
    • o código principal (a lógica de negócios específica da interatividade desejada)
    • o método"draw" (ativa os sombreadores e desenha pixels na tela);

O processo básico para renderizar conteúdo WebGL na tela é semelhante a este:

  1. Definir a matriz de perspectiva (ajusta as configurações da câmera que aponta para o espaço 3D, definindo o plano da imagem).
  2. Defina a matriz de posição (declare uma origem nas coordenadas 3D em relação às quais as posições são medidas).
  3. Preencha os buffers com dados (posição do vértice, cor, texturas etc.) a serem transmitidos ao contexto por meio dos sombreadores.
  4. Extrair e organizar dados dos buffers com os sombreadores e transmiti-los para a GPU.
  5. Chame o método de desenho para instruir o contexto a ativar os sombreadores, executar os dados e atualizar a tela.

Em ação, ela fica assim:

Definir a matriz de perspectiva...

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

definir a matriz de posição

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

definir a geometria e a aparência...

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

... preencher os buffers com dados e passá-los para o contexto...

// 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 chamar o método de desenho

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

A cada frame, lembre-se de limpar a tela se não quiser que elementos visuais em alfa fiquem empilhados uns sobre os outros.

The Venue

Além do túnel de partículas e grades, todos os outros elementos da interface do usuário foram criados em HTML / CSS e lógica interativa em JavaScript.

Desde o início, decidimos que os usuários deveriam interagir com a grade o mais rápido possível. Sem tela de apresentação, sem instruções, sem tutoriais. Basta usar o comando "começar". Se a interface estiver carregada, nada vai atrasar o carregamento.

Isso exigiu que analisássemos cuidadosamente como orientar um usuário iniciante em suas interações. Incluímos dicas sutis, como alterar a propriedade do cursor CSS com base na posição do mouse do usuário no espaço WebGL. Se o cursor estiver sobre a grade, nós o trocamos para um cursor em forma de mão (porque eles podem interagir gerando tons). Se você passa o cursor sobre o espaço em branco ao redor da grade, o trocamos por um cursor de cruz direcional (para indicar que eles podem girar ou explodir a grade em camadas).

Preparação para o show

LESS (um pré-processador CSS) e CodeKit (desenvolvimento da Web baseado em esteroides) realmente reduzem o tempo necessário para converter arquivos de design em HTML/CSS. Com eles, podemos organizar, escrever e otimizar o CSS de uma maneira muito mais versátil, aproveitando variáveis, misturas (funções) e até mesmo matemática.

Efeitos de palco

Usando as transições CSS3 e o backbone.js, criamos alguns efeitos realmente simples que ajudam a dar vida ao aplicativo e a fornecer aos usuários filas visuais que indicam qual instrumento eles estão usando.

As cores do Technitone.

O Backbone.js permite detectar eventos de alteração de cor e aplicar a nova cor aos elementos DOM adequados. As transições CSS3 aceleradas pela GPU processaram as mudanças no estilo das cores com pouco ou nenhum impacto no desempenho.

A maioria das transições de cores em elementos da interface foi criada com a transição das cores de fundo. Além dessa cor, colocamos imagens de plano de fundo com áreas estratégicas de transparência para permitir que a cor de fundo brilhe.

HTML: A base

Precisávamos de três regiões de cores para a demonstração: duas regiões de cores selecionadas pelo usuário e uma terceira região de cores mistas. Criamos a estrutura DOM mais simples que podíamos imaginar, compatível com transições CSS3 e com o menor número de solicitações HTTP para a nossa ilustração.

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

CSS: estrutura simples com estilo

Usamos posicionamento absoluto para colocar cada região no local correto e ajustamos a propriedade de posicionamento de fundo para alinhar a ilustração de fundo em cada região. Isso faz com que todas as regiões (cada uma com a mesma imagem de plano de fundo) pareçam um único 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;
  }

Foram aplicadas transições aceleradas por GPU que detectam eventos de mudança de cor. Aumentamos a duração e modificamos o easing em .color-mixed para criar a impressão de que as cores demorou para se misturar.

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

Acesse o HTML5 para conferir o suporte atual ao navegador e o uso recomendado para transições CSS3.

JavaScript: como fazer funcionar

Atribuir cores dinamicamente é simples. Pesquisamos o DOM em busca de elementos com nossa classe de cores e definimos a cor do plano de fundo com base nas seleções de cores do usuário. Aplicamos nosso efeito de transição a qualquer elemento no DOM adicionando uma classe. Isso cria uma arquitetura leve, flexível e escalonável.

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

Depois que as cores primárias e secundárias são selecionadas, calculamos o valor das cores mistas e atribuímos o valor resultante ao elemento DOM adequado.

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

Ilustração da arquitetura HTML/CSS: oferecendo a personalidade de três caixas que mudam de cor

Nosso objetivo era criar um efeito de iluminação divertido e realista que mantivesse sua integridade quando cores contrastantes eram colocadas em regiões de cores adjacentes.

Um PNG de 24 bits permite que a cor de fundo de nossos elementos HTML apareça através das áreas transparentes da imagem.

Transparências da imagem

As caixas coloridas criam bordas sólidas onde cores diferentes se encontram. Isso atrapalha os efeitos de iluminação realistas e foi um dos maiores desafios no design da ilustração.

Regiões de cores

A solução foi projetar a ilustração sem mostrar as bordas das regiões de cores nas áreas transparentes.

Bordas da região de cor

O planejamento para a criação foi fundamental. Uma rápida sessão de planejamento entre designer, desenvolvedor e ilustrador ajudou a equipe a entender como tudo precisava ser construído para que funcionasse em conjunto quando montado.

Confira o arquivo do Photoshop para ver um exemplo de como a nomenclatura de camadas pode transmitir informações sobre a construção em CSS.

Bordas da região de cor

Encore

Para usuários que não usam o Chrome, definimos uma meta para sintetizar a essência do aplicativo em uma única imagem estática. O nó da grade tornou-se o herói, os blocos de fundo fazem alusão à finalidade do aplicativo e a perspectiva presente no reflexo indica o ambiente 3D imersivo da grade.

Bordas da região de cor.

Para saber mais sobre o Technitone, acompanhe nosso blog.

A banda

Agradecemos por ler um pouco, mas talvez possamos incluir você em breve.