Fallstudie – Aufbau von Technitone.com

Sean Middleditch
Sean Middleditch
Technitone – Web-Audiotechnologie

Technitone.com ist eine Fusion aus WebGL, Canvas, Web Sockets, CSS3, JavaScript, Flash und der neuen Web Audio API in Chrome.

In diesem Artikel werden alle Aspekte der Produktion behandelt: der Plan, der Server, die Töne, die visuellen Elemente und einige Aspekte des Workflows, den wir für interaktive Designs nutzen. Die meisten Abschnitte enthalten Code-Snippets, eine Demo und einen Download. Am Ende des Artikels finden Sie einen Downloadlink, über den Sie alle als ZIP-Datei herunterladen können.

Das Produktionsteam von gskinner.com

Der Job

Wir sind bei gskinner.com keine Audioingenieure, aber wenn Sie uns vor eine Herausforderung stellen, finden wir einen Weg:

  • Nutzer zeichnen Töne in einem Raster, inspiriert von Andre's ToneMatrix
  • Töne werden mit gesampelten Instrumenten, Drum-Kits oder sogar eigenen Aufnahmen verbunden.
  • Mehrere verbundene Nutzer spielen gleichzeitig auf demselben Raster
  • …oder im Solomodus auf eigene Faust die Welt entdecken
  • Mit Einladungssitzungen können Nutzer eine Band organisieren und eine spontane Jamsession abhalten.

Wir bieten Nutzern die Möglichkeit, die Web Audio API mithilfe eines Tools zu erkunden, mit dem Audiofilter und -effekte auf ihre Töne angewendet werden.

Technitone von gskinner.com

Außerdem:

  • Kompositionen und Effekte von Nutzern als Daten speichern und zwischen Clients synchronisieren
  • Biete einige Farboptionen an, damit sie cool aussehende Songs zeichnen können.
  • Eine Galerie anbieten, in der Nutzer die Inhalte anderer Nutzer anhören, mit „Mag ich“ bewerten oder sogar bearbeiten können

Wir haben uns an der vertrauten Rastermetapher orientiert, sie im 3D-Raum schweben lassen, einige Beleuchtungs-, Textur- und Partikeleffekte hinzugefügt und sie in einer flexiblen (oder Vollbild-)CSS- und JS-gestützten Benutzeroberfläche untergebracht.

Road trip

Instrument-, Effekt- und Rasterdaten werden auf dem Client konsolidiert und serialisiert und dann an unser benutzerdefiniertes Node.js-Backend gesendet, um sie für mehrere Nutzer zu lösen, ähnlich wie bei Socket.io. Diese Daten werden mit den Beiträgen der einzelnen Spieler an den Client zurückgesendet, bevor sie an die entsprechenden CSS-, WebGL- und WebAudio-Ebenen verteilt werden, die für das Rendern der Benutzeroberfläche, Samples und Effekte bei der Wiedergabe mit mehreren Nutzern verantwortlich sind.

Die Echtzeitkommunikation mit Sockets sendet JavaScript auf den Client und JavaScript auf den Server.

Technitone-Serverdiagramm

Wir verwenden Node für alle Aspekte des Servers. Es ist ein statischer Webserver und unser Socket-Server in einem. Wir haben uns letztendlich für Express entschieden, einen vollständigen Webserver, der vollständig auf Node basiert. Er ist sehr skalierbar, hochgradig anpassbar und übernimmt die Low-Level-Server-Aspekte für Sie (genau wie Apache oder Windows Server). Als Entwickler müssen Sie sich dann nur noch auf die Entwicklung Ihrer Anwendung konzentrieren.

Demo für mehrere Nutzer (eigentlich nur ein Screenshot)

Diese Demo muss auf einem Node-Server ausgeführt werden. Da dieser Artikel keinen solchen enthält, haben wir einen Screenshot hinzugefügt, der zeigt, wie die Demo aussieht, nachdem Sie Node.js installiert, Ihren Webserver konfiguriert und die Demo lokal ausgeführt haben. Jedes Mal, wenn ein neuer Nutzer Ihre Demoinstallation besucht, wird ein neues Raster hinzugefügt und die Arbeit aller ist füreinander sichtbar.

Screenshot der Node.js-Demo

Node ist einfach. Durch die Kombination von Socket.io und benutzerdefinierten POST-Anfragen mussten wir keine komplexen Synchronisierungsroutinen erstellen. Socket.io verarbeitet dies transparent; JSON wird weitergegeben.

Wie einfach? Sieh dir das hier an.

Mit drei Zeilen JavaScript haben wir einen Webserver mit Express eingerichtet.

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

Noch ein paar mehr, um Socket.io für die Echtzeitkommunikation zu verknüpfen.

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

});

Jetzt warten wir einfach auf eingehende Verbindungen von der HTML-Seite.

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

…ein modulares Routing einrichten…

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

…einen Laufzeiteffekt anwenden (Faltung mit einer Impulsantwort)…

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

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

…einen weiteren Laufzeiteffekt (Verzögerung) anwenden…

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

…und dann hörbar machen.

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

Bei der Wiedergabe in Technitone geht es vor allem um die Planung. Anstatt ein Timerintervall festzulegen, das unserem Tempo entspricht, um Töne bei jedem Takt zu verarbeiten, haben wir ein kleineres Intervall eingerichtet, das Töne in einer Warteschlange verwaltet und plant. So kann die API die Vorarbeit leisten, indem sie Audiodaten auflöst und Filter und Effekte verarbeitet, bevor die CPU damit beauftragt wird, die Audiodaten hörbar zu machen. Wenn dieser Takt endlich kommt, hat der Prozessor bereits alle Informationen, die er benötigt, um das Endergebnis an die Lautsprecher zu senden.

Insgesamt musste alles optimiert werden. Wenn wir unsere CPUs zu sehr beansprucht haben, wurden Prozesse übersprungen, um den Zeitplan einzuhalten. Wir haben uns sehr bemüht, diesen ganzen Wahnsinn zu stoppen, wenn Sie in Chrome zu einem anderen Tab wechseln.

Lichtshow

Im Mittelpunkt steht unser Raster und der Partikeltunnel. Dies ist die WebGL von Technitone.

WebGL bietet eine deutlich bessere Leistung als die meisten anderen Ansätze zum Rendern von visuellen Elementen im Web, da die GPU in Verbindung mit dem Prozessor arbeitet. Diese Leistungssteigerung geht jedoch mit einer deutlich komplexeren Entwicklung mit einer viel steileren Lernkurve einher. Wenn Sie jedoch wirklich leidenschaftlich an interaktiven Inhalten im Web interessiert sind und möglichst wenige Leistungseinschränkungen wünschen, bietet WebGL eine mit Flash vergleichbare Lösung.

WebGL-Demo

WebGL-Inhalte werden in einem Canvas (HTML5-Canvas) gerendert und bestehen aus den folgenden Hauptbausteinen:

  • Objektknoten (Geometrie)
  • Positionsmatrizen (3D-Koordinaten)
    • Shader (eine Beschreibung der Geometrie, die direkt mit der GPU verknüpft ist)
    • den Kontext („Verknüpfungen“ zu den Elementen, auf die die GPU verweist)
    • Buffers (Pipelines zum Übergeben von Kontextdaten an die GPU)
    • den Hauptcode (die Geschäftslogik, die für die gewünschte interaktive Funktion spezifisch ist)
    • die Methode „draw“ (aktiviert die Shader und zeichnet Pixel auf den Canvas)

So werden WebGL-Inhalte auf dem Bildschirm gerendert:

  1. Perspektivmatrix festlegen (passt die Einstellungen für die Kamera an, die in den 3D-Raum blickt, und definiert die Bildebene).
  2. Legen Sie die Positionsmatrix fest (deklarieren Sie einen Ursprung in den 3D-Koordinaten, relativ zu dem die Positionen gemessen werden).
  3. Die Buffers werden mit Daten (Vertexposition, Farbe, Texturen usw.) gefüllt, die über die Shader an den Kontext übergeben werden.
  4. Daten aus den Buffers mit den Shadern extrahieren und organisieren und an die GPU übergeben.
  5. Rufen Sie die draw-Methode auf, um dem Kontext zu sagen, dass Shader aktiviert, mit den Daten ausgeführt und der Canvas aktualisiert werden soll.

In der Praxis sieht das so aus:

Perspektivmatrix festlegen…

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

…die Positionsmatrix festlegen…

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

…Geometrie und Erscheinungsbild definieren…

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

…füllen die Puffer mit Daten und übergeben sie an den Kontext…

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

…und die Methode „draw“ aufrufen

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

Denk daran, den Canvas in jedem Frame zu löschen, wenn du nicht möchtest, dass sich alphabasierte visuelle Elemente überlagern.

Der Veranstaltungsort

Neben dem Raster und dem Partikeltunnel wurden alle anderen UI-Elemente in HTML / CSS und die interaktive Logik in JavaScript erstellt.

Von Anfang an wollten wir, dass Nutzer so schnell wie möglich mit dem Raster interagieren können. Kein Startbildschirm, keine Anleitung, keine Tutorials – einfach loslegen. Wenn die Benutzeroberfläche geladen ist, sollte nichts die Leistung beeinträchtigen.

Daher mussten wir uns genau überlegen, wie wir Neulinge durch die Interaktionen führen. Wir haben subtile Hinweise hinzugefügt, z. B. dass sich die CSS-Cursoreigenschaft je nach Mausposition des Nutzers im WebGL-Raum ändert. Wenn sich der Cursor über dem Raster befindet, wird er in einen Handcursor umgewandelt, da Nutzer damit durch Zeichnen von Tönen interagieren können. Wenn der Mauszeiger auf dem Weißraum um das Raster herum schwebt, wird er durch einen Richtungskreuzcursor ersetzt, um anzuzeigen, dass das Raster gedreht oder in Ebenen aufgeteilt werden kann.

Vorbereitung auf die Präsentation

Mit LESS (ein CSS-Preprozessor) und CodeKit (Webentwicklung auf Steroiden) konnte die Zeit, die für die Umwandlung von Designdateien in HTML/CSS benötigt wurde, deutlich reduziert werden. So können wir CSS viel flexibler organisieren, schreiben und optimieren – mithilfe von Variablen, Mixins (Funktionen) und sogar Mathematik!

Bühneneffekte

Mithilfe von CSS3-Übergängen und backbone.js haben wir einige sehr einfache Effekte erstellt, die die Anwendung lebendiger machen und Nutzern visuelle Hinweise darauf geben, welches Instrument sie verwenden.

Die Farben von Technitone.

Mit Backbone.js können wir Farbänderungsereignisse erfassen und die neue Farbe auf die entsprechenden DOM-Elemente anwenden. Durch GPU-beschleunigte CSS3-Übergänge wurden die Farbstiländerungen mit nur geringen bis gar keinen Auswirkungen auf die Leistung verarbeitet.

Die meisten Farbübergänge bei den Benutzeroberflächenelementen wurden durch Übergänge der Hintergrundfarben erstellt. Auf dieser Hintergrundfarbe platzieren wir Hintergrundbilder mit strategisch platzierten transparenten Bereichen, damit die Hintergrundfarbe durchscheint.

HTML: Die Grundlage

Für die Demo benötigten wir drei Farbbereiche: zwei vom Nutzer ausgewählte Farbbereiche und einen dritten gemischten Farbbereich. Wir haben die einfachste DOM-Struktur erstellt, die uns einfiel, die CSS3-Übergänge unterstützt und für unsere Illustration die wenigsten HTTP-Anfragen erfordert.

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

CSS: Einfache Struktur mit Stil

Wir haben die absolute Positionierung verwendet, um jede Region an der richtigen Stelle zu platzieren, und das Attribut „background-position“ angepasst, um die Hintergrundillustration in jeder Region auszurichten. So wirken alle Regionen (mit jeweils demselben Hintergrundbild) wie ein einziges 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;
  }

Es wurden GPU-beschleunigte Übergänge angewendet, die auf Farbänderungsereignisse achten. Wir haben die Dauer verlängert und die Übergänge bei .color-mixed angepasst, um den Eindruck zu erwecken, dass es etwas dauert, bis sich die Farben vermischen.

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

Unter HTML5please finden Sie Informationen zur aktuellen Browserunterstützung und zur empfohlenen Verwendung von CSS3-Übergängen.

JavaScript: Die Umsetzung

Das Zuweisen von Farben ist ganz einfach. Wir suchen im DOM nach Elementen mit unserer Farbklasse und legen die Hintergrundfarbe basierend auf den Farbauswahlen des Nutzers fest. Wir wenden den Übergangseffekt auf jedes Element im DOM an, indem wir eine Klasse hinzufügen. So entsteht eine Architektur, die leichtgewichtig, flexibel und skalierbar ist.

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

Sobald die Primär- und Sekundärfarben ausgewählt sind, berechnen wir den Mischfarbwert und weisen den resultierenden Wert dem entsprechenden DOM-Element zu.

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

Illustrationen für die HTML/CSS-Architektur: Drei farbveränderliche Feldern eine Persönlichkeit verleihen

Unser Ziel war es, einen unterhaltsamen und realistischen Lichteffekt zu schaffen, der seine Integrität bewahrt, wenn kontrastierende Farben in benachbarten Farbbereichen platziert werden.

Bei einem 24-Bit-PNG kann die Hintergrundfarbe unserer HTML-Elemente durch die transparenten Bereiche des Bildes hindurchscheinen.

Bildfolien

Die farbigen Felder erzeugen harte Kanten, wo verschiedene Farben aufeinandertreffen. Das beeinträchtigt realistische Lichteffekte und war eine der größeren Herausforderungen beim Entwerfen der Illustration.

Farbregionen

Die Lösung bestand darin, die Illustration so zu gestalten, dass die Ränder der Farbbereiche niemals durch die transparenten Bereiche hindurch sichtbar sind.

Kanten der Region färben

Die Planung des Baus war entscheidend. Eine kurze Planungssitzung zwischen Designer, Entwickler und Illustrator half dem Team zu verstehen, wie alles aufgebaut werden musste, damit es nach dem Zusammenbau funktioniert.

Die Photoshop-Datei ist ein Beispiel dafür, wie die Benennung von Ebenen Informationen zur CSS-Struktur vermitteln kann.

Kanten der Region färben

Encore

Für Nutzer ohne Chrome wollten wir das Wesentliche der Anwendung in einem einzigen statischen Bild zusammenfassen. Der Rasterknoten wurde zum Hauptelement, die Hintergrundkacheln verweisen auf den Zweck der Anwendung und die Perspektive in der Reflexion verweist auf die immersive 3D-Umgebung des Rasters.

Farbe der Regionränder

Wenn du mehr über Technitone erfahren möchtest, behalte unseren Blog im Auge.

Das Armband

Vielen Dank fürs Lesen. Vielleicht jammen wir bald zusammen!