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

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

O Technitone.com é uma fusão de WebGL, Canvas, Web Sockets, CSS3, Javascript, Flash e a nova API Web Audio no Chrome.

Este artigo aborda todos os aspectos da produção: o plano, o servidor, os sons, os recursos visuais e alguns dos fluxos de trabalho que usamos para criar interatividade. A maioria das seções contém snippets de código, uma demonstração e um download. No final do artigo, há um link de download para você baixar todos os arquivos como um arquivo ZIP.

Equipe de produção do gskinner.com.

O show

Não somos engenheiros de áudio no gskinner.com, mas podemos ajudar você com um desafio:

  • Os usuários plotam tons em uma grade,"inspirados" na ToneMatrix do Andre.
  • Os tons são conectados a instrumentos sampleados, kits de bateria ou até mesmo às gravações dos usuários.
  • Vários usuários conectados jogam na mesma grade simultaneamente
  • …ou entrar no modo individual para explorar por conta própria
  • As sessões de convite permitem que os usuários organizem uma banda e façam uma jam session

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

Technitone por gskinner.com

Também:

  • Armazenar as composições e os efeitos dos usuários como dados e sincronizar entre os clientes
  • Ofereça algumas opções de cor para que eles possam desenhar músicas legais.
  • Ofereça uma galeria para que as pessoas possam ouvir, curtir ou até mesmo editar o trabalho de outras pessoas

Usamos a metáfora familiar da grade, a colocamos em um espaço 3D, adicionamos iluminação, textura e efeitos de partículas e a hospedamos em uma interface flexível (ou em tela cheia) orientada por CSS e JS.

Viagem de carro

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

A comunicação em tempo real com soquetes alimenta o JavaScript no cliente e no servidor.

Diagrama do servidor Technitone

Usamos o Node para todos os aspectos do servidor. Ele é um servidor da Web estático e nosso servidor de soquetes tudo em um. Usamos o Express, que é um servidor da Web completo criado totalmente no Node. Ele é super escalonável, altamente personalizável e processa os aspectos de servidor de baixo nível para você, assim como o Apache ou o Windows Server. Assim, você, como desenvolvedor, só precisa se concentrar na criação do aplicativo.

Demonstração multiusuário (é apenas uma captura de tela)

Essa demonstração precisa ser executada em um servidor Node. Como este artigo não é um servidor, incluímos uma captura de tela da aparência da demonstração depois que você instala o Node.js, configura seu servidor da Web e o executa localmente. Toda vez que um novo usuário acessa sua instalação de demonstração, uma nova grade é adicionada, e o trabalho de cada pessoa fica visível para as outras.

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 precisamos criar rotinas complexas para sincronização. O Socket.io processa isso de forma transparente. O JSON é transmitido.

Quão fácil? Veja isto.

Com três linhas de JavaScript, temos um servidor da Web em funcionamento 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 algumas coisas para vincular o socket.io para 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 vamos começar a detectar conexões recebidas 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 o 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 execução (convolução usando uma resposta de 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 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 depois 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 para a reprodução no Technitone é toda sobre programação. Em vez de definir um intervalo de timer igual ao tempo para processar sons a cada batida, definimos um intervalo menor que gerencia e programa sons em uma fila. Isso permite que a API realize o trabalho inicial de resolução de dados de áudio e de processamento de filtros e efeitos antes de atribuir a tarefa de torná-los audíveis à CPU. Quando esse momento finalmente chega, ele já tem todas as informações necessárias para apresentar o resultado líquido aos alto-falantes.

No geral, tudo precisava ser otimizado. Quando pressionamos muito as CPUs, os processos foram pulados (pop, clique, arranhão) para manter o cronograma. Fizemos um esforço sério para interromper toda a loucura se você mudar para outra guia no Chrome.

Show de luzes

O foco é o túnel de partículas e de grade. Esta é a camada WebGL do Technitone.

O WebGL oferece desempenho consideravelmente superior à maioria das outras abordagens de renderização de recursos visuais na Web, ao fazer com que a GPU trabalhe em conjunto com o processador. Esse ganho de desempenho vem com o custo de um desenvolvimento muito mais envolvido e uma curva de aprendizado muito mais íngreme. No entanto, se você realmente gosta de interatividade na Web e quer o mínimo de restrições de desempenho possível, o WebGL oferece uma solução comparável ao Flash.

Demonstração do WebGL

O conteúdo do WebGL é renderizado em uma tela (literalmente, a tela HTML5) e é composto por estes elementos básicos:

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

O processo básico para renderizar conteúdo do WebGL na tela é o seguinte:

  1. Define a matriz de perspectiva (ajusta as configurações da câmera que observa o espaço 3D, definindo o plano da imagem).
  2. Defina a matriz de posição (declare uma origem nas coordenadas 3D em que as posições são medidas).
  3. Preencha os buffers com dados (posição do vértice, cor, texturas…) para transmitir ao contexto pelos sombreadores.
  4. Extrair e organizar dados dos buffers com os shaders e transmiti-los para a GPU.
  5. Chame o método de desenho para informar ao contexto que ele precisa ativar os shaders, executar com os dados e atualizar a tela.

Ele fica assim em ação:

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

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

…definem alguma geometria e 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]);

…preencha os buffers com dados e transmita-os ao 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 chame o método de exibição

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

Em cada frame, lembre-se de limpar a tela se não quiser que os recursos visuais baseados em alfa se empilhem.

The Venue

Além da grade e do túnel de partículas, todos os outros elementos da interface 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, apenas "Go". Se a interface for carregada, não haverá nada que atrase o carregamento.

Por isso, precisamos analisar cuidadosamente como orientar um usuário iniciante nas interações. Incluímos dicas sutis, como a mudança da propriedade do cursor do CSS com base na posição do mouse do usuário no espaço do WebGL. Se o cursor estiver sobre a grade, ele será alterado para um cursor de mão, porque ele pode interagir ao traçar tons. Se o cursor estiver sobre o espaço em branco ao redor da grade, ele será substituído por um cursor de cruz direcional (para indicar que ele pode girar ou explodir a grade em camadas).

Como se preparar para o show

LESS (um pré-processador CSS) e CodeKit (desenvolvimento da Web turbinado) reduziram muito o tempo necessário para converter arquivos de design em HTML/CSS com stubs. Com elas, podemos organizar, escrever e otimizar o CSS de uma maneira muito mais versátil, usando variáveis, mix-ins (funções) e até mesmo matemática.

Efeitos de palco

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

As cores do Technitone.

O Backbone.js permite detectar eventos de mudança de cor e aplicar a nova cor aos elementos DOM apropriados. As transições CSS3 aceleradas por GPU processavam as mudanças de estilo de cor com pouco ou nenhum impacto na performance.

A maioria das transições de cor nos elementos da interface foi criada com a transição das cores de plano de fundo. Em cima dessa cor, colocamos imagens de plano de fundo com áreas estratégicas de transparência para deixar a cor do plano de fundo brilhar.

HTML: a base

Para a demonstração, precisamos de três regiões de cores: duas selecionadas pelo usuário e uma terceira com cores misturadas. Criamos a estrutura de DOM mais simples que conseguimos pensar que oferece suporte a transições CSS3 e as solicitações HTTP mais curtas para 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 o posicionamento absoluto para colocar cada região no local correto e ajustamos a propriedade background-position para alinhar a ilustração de plano 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 a transição em .color-mixed para criar a impressão de que as cores levaram tempo 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 HTML5please para conferir o suporte atual do navegador e o uso recomendado de transições CSS3.

JavaScript: Como fazer dar certo

Atribuir cores dinamicamente é simples. Procuramos no DOM qualquer elemento com nossa classe de cor e definimos a cor de fundo com base nas seleções de cor 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ária e secundária são selecionadas, calculamos o valor da cor misturada e atribuímos o valor resultante ao elemento DOM apropriado.

// 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 para arquitetura HTML/CSS: como dar personalidade a três caixas de mudança de cor

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

Um PNG de 24 bits permite que a cor de plano de fundo dos elementos HTML apareça nas áreas transparentes da imagem.

Transparências de imagem

As caixas coloridas criam bordas rígidas onde cores diferentes se encontram. Isso atrapalha os efeitos de iluminação realistas e foi um dos maiores desafios ao projetar a ilustração.

Regiões de cor

A solução foi projetar a ilustração de modo que as bordas das regiões de cores nunca apareçam nas áreas transparentes.

Colorir bordas da região

O planejamento do build foi fundamental. Uma sessão rápida de planejamento entre designer, desenvolvedor e ilustrador ajudou a equipe a entender como tudo precisava ser criado para que funcionasse junto quando montado.

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

Colorir bordas da região

Encore

Para usuários sem o Chrome, definimos uma meta de destilar a essência do aplicativo em uma única imagem estática. O nó da grade se tornou o herói, os blocos de plano de fundo fazem referência ao propósito do aplicativo, e a perspectiva presente na reflexão sugere o ambiente 3D imersivo da grade.

Colorir as bordas da região.

Se você quiser saber mais sobre o Technitone, fique ligado no nosso blog.

A banda

Agradecemos a leitura. Talvez possamos tocar com você em breve.