Studium przypadku – tworzenie Technitone.com

Sean Middleditch
Sean Middleditch
Technitone – internetowe rozwiązanie audio.

Technitone.com to połączenie WebGL, Canvas, Web Sockets, CSS3, Javascript, Flasha i nowego Web Audio API w Chrome.

W tym artykule omówimy każdy aspekt produkcji: plan, serwer, dźwięki, elementy wizualne oraz niektóre procesy, których używamy do tworzenia interaktywnych projektów. Większość sekcji zawiera fragmenty kodu, wersję demonstracyjną i możliwość pobrania. Na końcu artykułu znajduje się link do pobrania, który umożliwia pobranie wszystkich plików jako jednego pliku zip.

Zespół produkcyjny gskinner.com.

Zamówienie

W gskinner.com nie jesteśmy inżynierami dźwięku, ale jeśli postawisz przed nami wyzwanie, na pewno znajdziemy rozwiązanie:

  • Użytkownicy nanosić tony na siatkę, „zainspirowani” ToneMatrix ToneMatrix.
  • Dźwięki są przypisane do sampli instrumentów, zestawów perkusyjnych lub nawet własnych nagrań użytkowników.
  • wielu połączonych użytkowników gra na tej samej siatce jednocześnie,
  • …lub przejść do trybu solo, aby samodzielnie odkrywać świat.
  • Sesje z zaproszeniem umożliwiają użytkownikom tworzenie zespołów i improwizowanie.

Użytkownicy mogą zapoznać się z interfejsem Web Audio API za pomocą panelu narzędzi, który pozwala stosować filtry dźwiękowe i efekty do dźwięków.

Technitone przez gskinner.com

Dodatkowo:

  • przechowywać kompozycje i efekty użytkowników jako dane oraz synchronizować je na różnych klientach;
  • Dodaj opcje kolorów, aby użytkownicy mogli tworzyć ciekawe kompozycje.
  • udostępnić galerię, w której użytkownicy mogą słuchać, polubić lub nawet edytować utwory innych osób;

Użyliśmy znanej metafory siatki, umieściliśmy ją w przestrzeni 3D, dodaliśmy oświetlenie, tekstury i efekty cząstek, a następnie umieściliśmy ją w elastycznym (lub pełnoekranowym) interfejsie CSS i JS.

Wycieczka

Dane instrumentu, efektu i siatki są konsolidowane i serializowane na kliencie, a następnie wysyłane do niestandardowego backendu Node.js, aby rozwiązać problemy wielu użytkowników w taki sam sposób jak w przypadku Socket.io. Dane te są wysyłane z powrotem do klienta z uwzględnieniem wkładu każdego z graczy, a następnie rozprowadzane do odpowiednich warstw CSS, WebGL i WebAudio odpowiedzialnych za renderowanie interfejsu użytkownika, próbek i efektów podczas odtwarzania przez wielu użytkowników.

Komunikacja w czasie rzeczywistym z gniazdami przesyłającymi dane JavaScript na klienta i JavaScript na serwer.

Schemat serwera Technitone

Używamy Node do wszystkich aspektów serwera. Jest to serwer WWW statyczny i serwer socketowy w jednym. Ostatecznie zdecydowaliśmy się na użycie Expressa, który jest pełnoprawnym serwerem WWW zbudowanym całkowicie na podstawie Node. Jest ona bardzo skalowalna, można ją w dużej mierze dostosować do potrzeb, a poza tym obsługuje za Ciebie aspekty serwera na niskim poziomie (tak jak Apache czy Windows Server). Jako deweloper możesz się wtedy skupić tylko na tworzeniu aplikacji.

Demonstracja wersji dla wielu użytkowników (to tylko zrzut ekranu)

Ten pokaz wymaga uruchomienia na serwerze Node.js, a ponieważ w tym artykule nie jest to możliwe, zamieszczamy zrzut ekranu pokazujący, jak wygląda ten pokaz po zainstalowaniu Node.js i skonfigurowaniu serwera WWW oraz uruchomieniu go lokalnie. Za każdym razem, gdy nowy użytkownik odwiedzi Twoją instalację demonstracyjną, zostanie dodana nowa siatka, a dzieła wszystkich użytkowników będą widoczne dla siebie nawzajem.

Zrzut ekranu pokazujący aplikację Node.js Demo

Węzeł jest prosty. Dzięki połączeniu Socket.io i niestandardowych żądań POST nie musieliśmy tworzyć skomplikowanych procedur synchronizacji. Socket.io obsługuje to w sposób przejrzysty; JSON są przekazywane.

Jak łatwo? Obejrzyj ten film.

Dzięki 3 wierszom kodu JavaScriptu mamy działający serwer WWW z 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/'));

Kilka dodatkowych informacji o połączeniu socket.io w celu komunikacji w czasie rzeczywistym.

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

});

Teraz zaczynamy nasłuchiwać przychodzących połączeń ze strony 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.
}

…skonfiguruj routing modularny…

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

…zastosuj efekt czasowy (konwolucję za pomocą odpowiedzi impulsowej)…

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

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

…apply another runtime effect (delay)…

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

…a następnie uczyń go słyszalnym.

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

Nasze podejście do odtwarzania w Technitone polega na planowaniu. Zamiast ustawiać interwał zegara równy naszemu tempo, aby przetwarzać dźwięki co uderzenie, ustawiamy mniejszy interwał, który zarządza i planuje dźwięki w kolejce. Dzięki temu interfejs API może z góry przetworzyć dane audio oraz przetworzyć filtry i efekty, zanim procesor wykona zadanie polegające na przetworzeniu dźwięku. Gdy w końcu nadejdzie ten moment, system ma już wszystkie informacje potrzebne do przedstawienia głośnikom wyniku netto.

Ogólnie rzecz biorąc, wszystko wymagało optymalizacji. Gdy procesory były zbyt mocno obciążone, procesy były pomijane (pop, click, scratch), aby zachować harmonogram. Dołożyliśmy wszelkich starań, aby zatrzymać to szaleństwo, jeśli przeskoczysz na inną kartę w Chrome.

Pokaz światła

Na pierwszym planie znajduje się nasza siatka i tunel cząsteczkowy. To warstwa WebGL w Technitone.

WebGL zapewnia znacznie wyższą wydajność niż większość innych metod renderowania grafiki w internecie, ponieważ GPU współpracuje z procesorem. Zwiększenie wydajności wiąże się z koniecznością znacznie bardziej zaawansowanego rozwoju i znacznie bardziej stromej krzywej uczenia. Jeśli jednak naprawdę interesują Cię interakcje w internecie i chcesz, aby były one jak najmniej ograniczone pod względem wydajności, WebGL to rozwiązanie porównywalne z Flashem.

Prezentacja WebGL

Treści WebGL są renderowane na płótnie (dosłownie na płótnie HTML5) i składają się z tych podstawowych elementów:

  • wierzchołki obiektu (geometria),
  • macierze pozycji (współrzędne 3D)
    • shadery (opis wyglądu geometrii, bezpośrednio powiązany z GPU);
    • kontekstu („skrótów” do elementów, do których odwołuje się GPU);
    • bufory (potoki do przekazywania danych kontekstowych do GPU);
    • kod główny (logika biznesowa dotycząca interakcji);
    • metoda „draw” (aktywuje shadery i rysuje piksele na płótnie);

Podstawowy proces renderowania treści WebGL na ekranie wygląda tak:

  1. Ustaw macierz perspektywy (dostosowuje ustawienia kamery, która patrzy w przestrzeń 3D, definiując płaszczyznę obrazu).
  2. Ustaw tablicę pozycji (oznacz punkt początkowy w układzie współrzędnych 3D, względem którego mierzone są pozycje).
  3. Wypełnij bufory danymi (pozycja wierzchołka, kolor, tekstury itp.), aby przekazać je kontekstowi za pomocą shaderów.
  4. wyodrębniać i porządkować dane z buforów za pomocą shaderów i przekazywać je do GPU.
  5. Wywołaj metodę draw, aby poinformować kontekst o potrzebie aktywowania shaderów, uruchomienia danych i zaktualizowania rysunku.

Oto jak to działa:

Ustaw macierz perspektywy…

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

…ustawiliśmy tablicę pozycji…

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

…określanie geometrii i wyglądu…

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

…wypełnij bufory danymi i przekaż je do kontekstu…

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

…i wywołuj metodę 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);

Jeśli nie chcesz, aby wizualizacje oparte na kanale alfa nakładały się na siebie, pamiętaj, aby w każdym ujęciu wyczyścić kanwę.

Obiekt

Poza siatką i tunelem cząsteczkowym wszystkie inne elementy interfejsu zostały utworzone w HTML / CSS, a logika interakcji – w JavaScript.

Od samego początku zależało nam na tym, aby użytkownicy mogli jak najszybciej zacząć korzystać z siatki. Bez ekranu powitalnego, bez instrukcji, bez samouczków, tylko „Go”. Jeśli interfejs jest wczytany, nie powinno być niczego, co mogłoby spowolnić proces.

Wymagało to od nas dokładnego przeanalizowania sposobu, w jaki należy pokierować użytkownika podczas jego pierwszej interakcji. Dodaliśmy subtelne wskazówki, takie jak zmiana właściwości kursora CSS w zależności od położenia myszy użytkownika w przestrzeni WebGL. Jeśli kursor znajduje się nad siatką, zmieniamy go na kursor w kształcie dłoni (ponieważ użytkownicy mogą wchodzić w interakcję poprzez nanoszenie tonów). Jeśli kursor znajduje się w pustym miejscu wokół siatki, zamieniamy go na kursor w kształcie krzyża (aby wskazać, że można obrócić siatkę lub rozłożyć ją na warstwy).

Przygotowanie do pokazu

LESS (przetwarzacz CSS) i CodeKit (rozwój stron internetowych na sterydach) znacznie skrócili czas potrzebny na przekształcenie plików z projektem w stuby HTML/CSS. Umożliwiają one organizowanie, pisanie i optymalizowanie kodu CSS w bardziej elastyczny sposób – za pomocą zmiennych, wtyczek (funkcji) a nawet obliczeń matematycznych.

Efekty sceniczne

Korzystając z przejść CSS3backbone.js, stworzyliśmy bardzo proste efekty, które nadają aplikacji życia i pozwalają użytkownikom zobaczyć wizualne wskazówki dotyczące tego, którego instrumentu używają.

Kolory Technitone.

Backbone.js pozwala nam wychwytywać zdarzenia zmiany koloru i stosować nowy kolor do odpowiednich elementów DOM. Przejścia CSS3 przyspieszone przez GPU obsługiwały zmiany stylu kolorów z niewielkim wpływem na wydajność.

Większość przejść kolorów w elementach interfejsu została utworzona przez przejścia kolorów tła. Na tym kolorze tła umieszczamy obrazy tła z przejrzystymi obszarami, aby kolor tła był widoczny.

HTML: The Foundation

Na potrzeby demonstracji potrzebowaliśmy 3 regionów kolorów: 2 wybranych przez użytkownika i 1 mieszany. W celu zilustrowania tego zagadnienia utworzyliśmy najprostszą strukturę DOM, jaką udało nam się wymyślić, która obsługuje przejścia CSS3 i wysyła jak najmniej próśb HTTP.

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

CSS: prosta struktura ze stylem

Użyliśmy pozycjonowania bezwzględnego, aby umieścić każdy region w odpowiednim miejscu, i dopasowaliśmy właściwość background-position, aby wyrównać ilustrację tła w każdym regionie. Dzięki temu wszystkie regiony (każdy z tym samym obrazem tła) wyglądają jak jeden element.

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

Zastosowano przejścia przyspieszone przez GPU, które nasłuchują zdarzeń zmiany koloru. Zwiększyliśmy czas trwania i zmieniliśmy wygładzanie w przypadku .color-mixed, aby stworzyć wrażenie, że mieszanie kolorów zajęło trochę czasu.

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

Informacje o obsługiwanych przeglądarkach i zalecanym sposobie korzystania z przejść CSS3 znajdziesz na stronie HTML5please.

Kod JavaScript: działanie

Dynamiczne przypisywanie kolorów jest proste. Szukamy w DOM elementu z naszą klasą koloru i ustawiamy kolor tła na podstawie wybranych przez użytkownika kolorów. Dodajemy klasę do dowolnego elementu w DOM, aby zastosować do niego efekt przejścia. Dzięki temu architektura jest lekka, elastyczna i skalowalna.

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

Po wybraniu kolorów podstawowego i dodatkowego obliczamy ich wartość mieszaną i przypisujemy wynikową wartość do odpowiedniego elementu 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+')';
}

Ilustracja dotycząca architektury HTML/CSS: nadawanie osobowości 3 kolorom

Naszym celem było stworzenie zabawnego i realistycznego efektu oświetlenia, który zachowa spójność, gdy kontrastujące kolory zostaną umieszczone w sąsiednich regionach kolorów.

24-bitowy format PNG pozwala wyświetlać kolor tła elementów HTML przez przezroczyste obszary obrazu.

Przeźroczystości obrazu

Kolorowe pola tworzą ostre krawędzie w miejscach styku różnych kolorów. To utrudnia realistyczne efekty oświetlenia i było jednym z większych wyzwań podczas projektowania ilustracji.

Regiony kolorów

Rozwiązaniem było zaprojektowanie ilustracji w taki sposób, aby krawędzie kolorowych obszarów nigdy nie były widoczne przez przezroczyste obszary.

Kolor krawędzi regionu

Planowanie kompilacji było kluczowe. Szybka sesja planowania z udziałem projektanta, programisty i ilustratora pomogła zespołowi zrozumieć, jak wszystko powinno być zbudowane, aby po złożeniu elementy działały razem.

Plik Photoshopa to przykład tego, jak nazwy warstw mogą przekazywać informacje o budowie CSS.

Kolor krawędzi regionu

Encore

W przypadku użytkowników bez Chrome postawiliśmy sobie za cel skoncentrowanie istoty aplikacji w jednym statycznym obrazie. Element siatki stał się elementem głównym, kafelki tła nawiązują do celu aplikacji, a perspektywa widoczna w odbiciu sugeruje wciągające środowisko 3D siatki.

Kolor krawędzi regionu.

Jeśli chcesz dowiedzieć się więcej o Technitone, czytaj nasz blog.

Pasek

Dziękuję za przeczytanie tego artykułu. Może wkrótce zaprosimy Cię do wspólnej zabawy.