Benutzer-Werkzeuge

Synthesizer per Browser konfigurieren mit SynthCommander

WebMIDI - Musikinstrumente per Browser steuern

Das 1982 eingeführte Musical Instrument Digital Interface (MIDI) entstand in der Blütezeit analoger Synthesizer und war die erste Möglichkeit, Computer Musik abspielen zu lassen oder (Home-)Computer mit Keyboards oder Synthesizern zu verbinden. Der Atari ST etwa besaß hierzu direkt MIDI-Anschlussbuchsen. Die ersten PC Soundkarten und Heimcomputer enthielten zwar Synthesizer-Chips, waren aber zu langsam und hatten zu wenig Speicher, um per Mikrofon Tonspuren aufzunehmen und wiederzugeben. MIDI-Dateien bzw. Datenströme sind sehr kompakt, weil sie Musik nur als Sequenz von Noten und ein paar Einstellwerten übertragen, um damit vergleichsweise primitive bzw. analoge Tongeneratoren zu parametrisieren. Die Musik der ersten (Heim-)Computer war also MIDI-Musik.

Mit steigender Rechnerleistung und Speichergröße verdrängten Sampling-Techniken bzw. „gesampelte“ Sounds die analogen Synthesizer lange aus dem Musik- und Unterhaltungs- Mainstream und MIDI spielte nur in diversen Genres der elektronischen Musik eine Rolle. So verlor auch MIDI an Bedeutung und behielt nur für einige Musikgeräte-Hersteller größere Bedeutung. So ist zumindest erstaunlich, dass es seit 2015 wieder einen Browser-Standard für eine Web MIDI API gibt, die in Google's Chrome-Browser und Opera bereits implementiert ist. Da bei Mobiltelefonen MIDI schon seit seligen Nokia-Zeiten für Klingeltöne genutzt wurde, ist es aber nicht verwunderlich, dass Android Smartphones die Web Midi API auch bereitstellen. Da man MIDI-Datenströme -wie heute fast alle alten Protokolle - per IP oder USB-Anschlüssen ausbreiten kann, ist es auch kein Hexenwerk MIDI-Geräte drahtlos oder ohne spezielle MIDI-Kabel mit nur der geforderten Baudrate von lächerlichen 31250 Bits/sec zu übertragen. Geschwindigkeit ist also eher kein Problem, die zeitliche Synchronisation mehrerer Instrumente durch hohe Latenzzeiten hingegen schon. Um in Corona-Zeiten die Musiker einer über die Welt verstreuten Band im richtigen Takt zu halten, kann also auch mit Web MIDI noch ein Problem sein.

Im Zeitalter von Hackern und Datenschutz hat Google inzwischen auch die Web MIDI API als Sicherheitsleck erkannt und deshalb funktioniert sie bei einigen Versionen der Chrome bzw. Chromium Browser nicht. Zukünftig sollen jedenfalls nur Server mit HTTPS-Verbindung erst nach Bestätigung durch den Benutzer den Zugriff auf MIDI-Geräte des Benutzers erhalten - bei einigen Versionen der Chrome/Chromium - Browser funktioniert die WebAPI also nicht.

Alternativ gibt es für einige andere Browser (etwa Firefox) die Extension Jazz MIDI, die noch ohne solche „Sicherheitsmerkmale“ auskommt. Man sollte sich aber gut überlegen, ob man Web MIDI auf Sicherheit-relevanten Rechnern nutzen möchte - es muss ja nicht gerade ein Rechner sein, mit dem man regelmäßig im Internet unterwegs ist - vielleicht möchte man nur mit einem preiswerten Bastelrechner wie einem Raspberry PI ein paar Einstellungen am Synthesizer ausprobieren.

Die direkte Nutzung der standardisierten Web MIDI API ist etwas mühsam, da MIDI alle Daten als Folge von 7-Bit Zahlen (0..127) darstellt, was keinen direkten Bezug zu Daten wie Noten (C,Cis,D,Dis..) oder Einstellparametern verrät. Von großem Nutzen ist deshalb die schöne Bibliothek webmidi, die man per NPM direkt einbinden kann. Ich verwende sie natürlich auch für SynthCommander. Die Library erkennt alle am Rechner erreichbaren MIDI Ein- und Ausgänge und kann Noten direkt über ihren Namen mit festlegbarer Abspieldauer abspielen, Tastendrücke von Keyboards auswerten und alle unterstützten Steuerbefehle und Programmwechsel an Instrumente schicken.

SynthCommander direkt ausprobieren

Eine aktuelle Kopie des SynthCommander kann mit einem Chrome-Browser direkt gestartet werden. Ist ein MIDI-Ausgabegerät am Computer angeschlossen, kann man es per select MIDI output device auswählen, aus der Auswahlliste synthesizer model lassen sich bereits unterstützte Geräte auswählen. Für weitere Geräte benötigt man nur eine in JSON- oder YAML- Notation geschriebene Modelldatei.

Über eine Menüleiste kann dann eine der Funktionen Monitor MIDI input oder Control MIDI output ausgewählt werden, mit der entweder Einstellungsänderungen von Reglern am MIDI-Gerät (Input) verfolgt werden oder Einstellungen des Synthesizers konfiguriert werden können (Output). Auch ohne angeschlossenen Synthesizer werden diese Seitenbereiche sichtbar, sobald eine Modelldatei ausgewählt wurde. Wenn einer der unterstützten Synthesizer angeschlossen ist, kann man bei Control MIDI output einen Test-Ton wiedergeben und die Wirkung der darunter angezeigten Regler direkt hören.

Wenn auch bei eingeschaltetem Testton (Tone selection Regler bewegen) nichts zu hören ist, kann das an einem am linken Anschlag (0) eingestellten Cutoff Regler eines Filters oder einer komplett herunter geregelten Hüllkurve (Envelope) liegen. Die zugehörigen Regler sollte man dann mal etwas nach rechts schieben. Auch andere Regler lassen einen Synthesizer evtl. „stummschalten“ - hier muss man etwas probieren oder die die Eigenschaften des Synthesizers erlernen.

SynthCommander und sein Quellcode sind frei (unter MIT Lizenz) und der Quellcode kann bei GitHub herunter geladen werden. In meinem ersten Beitrag findet man die Grundlagen, wie man diesen Quellcode übersetzen und ausführen kann. Dieser Artikel beschreibt nun alle Teile dieses Quellcodes als Beispiel für eine Angular Single-Page-Webanwendung.

Anatomie einer Angular Anwendung

Wie bereits im oben genanntem Angular Einführungsartikel beschrieben, liegen nahezu alle für eine konkrete Anwendung relevanten Quelldateien im Verzeichnis src/app. Lediglich in package.json aufgeführte zusätzliche Bibliotheken oder in styles.scss definierte allgemeine Gestaltungsmerkmale (corporate identity, Twitter Bootstrap etc.) befinden sich außerhalb von Verzeichnis src/app.

Angular-Anwendungen bestehen aus einer Hierarchie von Modulen und das Hauptmodul der Anwendungen liegt per Konvention in der Datei src/app/app.module.ts. Modul-Definitionen sind TypeScript-Klassen mit einem Decorator namens @NgModule und haben per Konvention Dateinamen mit der Erweiterung .module.ts.

Der Zweck solcher Module ist die Aufstellung sämtlicher Komponenten, Services, Importe und Exporte des Moduls. Auch Angular selbst besteht aus einer Hierarchie von Modulen, von denen in jede Angular-Anwendung einige importiert werden. Das Hauptmodul app.module.ts exportiert selbst keine Module, importiert aber alle Module, die als Single Page Anwendung zusammen geladen werden. Module die erst bei Bedarf (lazy) geladen werden, erscheinen nur in Navigations-Routen (siehe später in diesem Kapitel).

SynthCommander besteht als recht kleine App nur aus einem Hauptmodul mit einigen Services, Komponenten und Direktiven, die jeweils als dekorierte TypeScript Klassen mit den Dekoratoren @Injectable, @Component und @Directive dekoriert sind und per Konvention die Dateinamen-Erweiterungen .service.ts und component.ts haben (Direktiven beziehen sich oft nur auf eine Komponente und sind dann in deren Datei enthalten).

@NgModule-Klassen sind quasi der „Bauplan“ eines Moduls und darin etwa mit „Make“-Dateien anderer Programmiersprachen vergleichbar. Nur dort eingetragene und exportierte Komponenten sowie Direktiven gehören zum Modul, werden beim Build übersetzt und werden damit auch für andere Komponenten des Moduls sichtbar.

Was ist aber nun die Aufgabe von Service-, Component- und Directive- Klassen und wieso heißt der Dekorator von Services @Injectable ?

@Directive

Eine Directive wird als einfachste Klasse meist dazu benutzt, um die in Components enthaltenen Vorlagen (Templates) um spezielle Attribute zu erweitern. Bei den meist verwendeten HTML-Templates, die von Angular ja in per Javascript aufgebaute DOM-Modelle übersetzt werden, ermöglichen solche Attribute Manipulationen direkt am DOM-Modell per Javascript. In der OutputComponent Komponente von SynthCommander aktualisiert die Direktive slidermove etwa die Anzeige des Werts value eines range-input-Elements (Slider) in einem danach folgendem span-Textfeld:

@Directive({
  selector: '[slidermove]'
})
export class SliderMoveDirective {
  @HostListener('input', ['$event']) onSliderMove($event) {
    let target = $event.target.nextSibling;
    target.textContent = $event.target.value;
  }
}

Dazu wird dann bei den gewünschten input-Tags lediglich das neue Attribut slidermove eingetragen.

  <input type="range" class="col-9" min="46" max="92" [value]="testNote"
        (input)="onNoteChange($event)" slidermove>
  <span class="col-1">{{testNote}}</span>

So führt ein simpler Markup mit dem Wort slidermove zur Ausführung der Direktiven, welche den im <span> enthaltenem Text ändert, sobald der Slider bewegt wird. Früher hätte ich so etwas z.B. mit ein bisschen :de:jQuery gemacht. In einer Angular-App sollte man aber wildes Herumfuhrwerken im DOM unterlassen, damit Angular's Zustandsüberwachung nicht kompromittiert wird. Direktiven enthalten im Gegensatz zu den Components keine eigenen DOM-Elemente bzw. Templates, sondern nur TypeScript-Code, der sich ins DOM-Modell einklinkt, indem man ihren selector in den Template-Code einfügt - etwa als neues, in HTML gar nicht definiertes Attribut.

Angular selbst stellt viele - teils recht komplexe - Direktiven bereit, etwa um auch Kontrollstrukturen (ngIf) und Schleifen (ngFor) in Templates zu ermöglichen.

@Component

Eine Komponente enthält in seinem Dekorator @Component ein HTML-Template und ein Array aus CSS-Stilen, die innerhalb der Komponente gelten. Die Typescript-Klasse selbst enthält vor allem Code, der im Template gebundene Daten steuert. Spezielle (als Direktiven implementierte) Attribute erlauben im Template Fallunterscheidungen, Schleifen und andere Kontrollstrukturen, die über den Code gesteuert und an Daten gebunden werden.

Beispiel für ein simples Menüsystem
@Component({
  selector: 'todo-list',
  template: `
    <ul>
      <li *ngFor="let todo of todos; let idx=index">
        <a class="blue" (click)="doit(idx)">{{todo}}</a>
      </li>
    </ul>
  `,
  styles: ['blue {background-color: blue}']
})
export class EmptyComponent {
  todos = ['konzept erstellen', 'daily scrum', 'coden', 'fehlersuche', 'projektsitzung' ];
 
  doit(idx: number) {
    console.log(idx);
  }
}
  • Mit dem Selektor todo-list kann dieses Template als Pseudo-HTML-Tag in Templates anderer Komponenten eingesetzt werden
  • im Template wird die von Angular bereitgestellte Direktive ngFor genutzt, um für jedes Array-Element von todos einen neuen Listeneintrag zu rendern
  • bei ngFor können nicht nur die Werte in einem Array sondern auch ihr index Schleifen steuern
  • der Click-Eventhandler doit wird mit dem Listenindex idx aufgerufen, wenn ein Menüeintrag angeklickt wird
  • alle Properties und Funktionen der Typescript-Implementierung können im Template direkt angesprochen werden

Service (Dekorator @Injectable)

Ein Service wird mit dem Dekorator @Injectable dekoriert. Ein Service ermöglicht Komponenten, die Dienste des Services zu nutzen, ohne selbst eine Instanz des Services erzeugen und verwalten zu müssen und damit eine direkte Abhängigkeit von dessen Implementierung zu verursachen. Abhängigkeiten mehrerer Komponenten und Services voneinander machen den Code kompliziert und schwer wartbar.

Es reicht, im Konstruktor der Komponentenklasse einen Übergabeparameter auf den Service einzutragen, den Service im Modul zu registrieren und Angular übernimmt hinter den Kulissen das Anlegen einer Service-Instanz. Diese Technik ist als :de:Dependency_Injection (oder Inversion of Control, IoC) bekannt, was sich namentlich durch den Dekorator @Injectable ausdrückt.

Services besitzen keine Visualisierung oder Benutzer-Interaktion und realisieren meist Ein/Ausgabe - Schnittstellen zu Backends wie Webservices, Datenbanken oder Systemschnittstellen (wie MIDI im Falle von SynthCommander). Daten werden von Services als einzelne TypeScript (statisch typisiert) bzw. Javascript (untypisiert) - Objekte - oder Datenströme solcher Objekte - transportiert. Datenströme können abgeschlossen oder unendlich sein. Eine Datenbank-Abfrage etwa ist häufig abgeschlossen, während eine Verbindung zu einer Tastatur, einer Maus oder einem GPS-Empfänger niemals ein definiertes Ende definiert.

Eng mit Services sind deshalb Varianten von Generic - Klassen wie Observable<> oder Subject<> verbunden. In Angular integrierte Services wie z.B. der HttpClient - Service liefern Daten grundsätzlich in Observables verpackt und niemals direkt ab, weil die meisten Services nur asynchron Daten liefern können. Gerade für stets single-threaded ablaufende Javascript-Anwendungen sind synchrone Funktionsaufrufe selten praktikabel, weshalb selbst bei :de:Node.js schon beim Dateizugriff oder gar Web-Request nicht auf Erledigung gewartet werden kann, ohne dass das Programm unbrauchbar langsam wird.

Observer/Observable (deutsch: Beobachter) ist ein bekanntes Entwurfsmuster, dass man sich als eine Art Abonnementmodell vorstellen kann. So erhält man Daten von einem Observable erst nach Aufruf der Observable-Funktion subscribe (deutsch: abonnieren), die asynchron Funktionen für Empfang von Daten, Fehler oder Abschluss der Übertragung aufruft. Dazu ein Beispiel ähnlich dem des PatchfileService aus SynthCommander:

class PatchDisplay() {
    constructor(private http: HttpClient) {} // Angular "injiziert" seinen HttpClient Service
 
    // nutzt SynthCommander's Webservice, um eine Liste von Patchdateien zu lesen
    // liefert bei Verfügbarkeit als Ergebnis ein String-Array mit Dateinamen
    getPatchfiles():Observable<string[]> {
        return this.http.get<string[]>('api/list')
    }
 
    listPatchFiles() {
      this.getPatchfiles().subscribe(  // abonnieren der Webservice-Abfrage oben
        patches => console.log(`got a patch: ${patches}`), // gibt das erhaltene String-Array aus
        err => console.log(`access failure: ${err}`), // der Webservice meldete einen Fehler
        () => console.log('download completed.') // alle Daten übertragen, das Observable ist danach geschlossen
    )}
}

Der Konstruktor macht eine von Angular bereitgestellte HttpClient-Instanz als Member zugänglich, sodass getPatchfiles sie nutzen kann. Dabei wird die Generic-Function get<string[]> verwendet, die vom Webservice gelieferte JSON-Daten direkt statisch typisiert übergibt. Die Methode listPatchfiles abonniert nun das von getPatchfiles bereitgestellte Observable und kann nun je nach Sachlage drei Funktionen (hier als Pfeilfunktion bzw. Lambda geschrieben) aufrufen:

  • die als erster Parameter an subscribe übergebene Funktion wird jedesmal aufgerufen, wenn das Observable Daten abliefert
  • die als zweiter Parameter übergebene Funktion wird im Fehlerfall mit einer Fehlermeldung err aufgerufen
  • die als dritter Parameter übergebene Funktion wird aufgerufen, wenn keine weiteren Aufrufe mehr erfolgen (Observable „hat fertig“)

Der dritte Parameter macht beim hier verwendeten Webservice eigentlich keinen Sinn, weil für diesen kein Ende festgelegt ist. Würde der Service die Array-Elemente einzeln liefern (bzw. müsste die App daraus selbst erst ein Array aufbauen), wäre so eine Ende- Meldung aber sehr wichtig, denn die App könnte mit den Daten erst etwas anfangen, wenn sie vollständig angekommen wären. Letzteres hat aber auch den Nachteil, dass das Observable danach geschlossen wäre und erst ein neues erzeugt werden müsste, um wieder Daten zu erhalten.

SynthCommander's Hauptmodul (app.module.ts)

import {BrowserModule} from '@angular/platform-browser';
import {RouterModule, Routes} from "@angular/router";
import {NgModule} from '@angular/core';
 
import {WebmidiService} from "./webmidi.service";
import {PatchfileService} from "./patchfile.service";
import {SynthmodelService} from "./synthmodel.service";
 
import {AppComponent} from './app.component';
import {OutputComponent, SliderMoveDirective} from "./output.component";
import {InputComponent} from "./input.component";
import {EmptyComponent} from "./empty.component";
import {HttpClientModule} from "@angular/common/http";
 
const routes: Routes = [
  {path: '', redirectTo: '/output', pathMatch: 'full'},
  {path: 'info', component: EmptyComponent},
  {path: 'input', component: InputComponent},
  {path: 'output', component: OutputComponent}
];
 
@NgModule({
  declarations: [
    AppComponent, SliderMoveDirective, InputComponent, OutputComponent,
    EmptyComponent
  ],
  imports: [
    BrowserModule, RouterModule.forRoot(routes,{useHash: true}), HttpClientModule
  ],
  providers: [{provide: WebmidiService}, {provide: PatchfileService}, {provide: SynthmodelService}],
  bootstrap: [AppComponent]
})
export class AppModule {
}

Den größten Teil des Codes nimmt die Import-Liste ein, da hier die Dateipfade aller Direktiven, Komponenten und Services bekannt gegeben werden müssen, damit jene der Modulklasse bzw. Angular und dessem integrierten :de:Webpack bekannt sind. Webpack kann so jene „Chunk“-Dateien erzeugen, die später als App an den Browser ausgeliefert werden und die nur noch Javascript - also kein HTML oder CSS mehr enthalten.

Der Dekorator der ansonsten leeren Modulklasse AppModule listet die folgenden Bereiche:

Bereich Inhalt
declarations eine Liste aller im Modul enthaltenen Direktiven und Komponenten (keine Services!)
imports eine Liste vom Modul verwendeter Module (sind hier alle durch Angular bereit gestellt)
exports entfällt beim Hauptmodul
providers eine Liste der vom Modul bereit gestellten Services (Voraussetzung für Dependency Injection)
bootstrap die Komponente, welche die App startet

Seiten-Navigation im Browser

Besondere Aufmerksamkeit verdient das als Angular-Service importierte RouterModule. Diesem wird bereits in der Import-Auflistung (imports) per Aufruf forRoot(routes,{useHash: true}) eine Routes-Tabelle übergeben, die hier auch bereits in app.module.ts abgelegt ist, bei größeren Apps aber meist in einer gesonderten Datei erscheint.

Hierbei werden die Navigationsmöglichkeiten des Browsers genutzt, um mittels URLs Funktionen bzw. Seiten einer Web-Anwendung anzusteuern. Das kann über Links, automatische Weiterleitungen oder seitens des Benutzers direkt über die Browser-Addresszeile geschehen. Bei klassischen Webseiten führt das zum Laden neuer Seiten vom Webserver.

SynthCommander ist aber eine :de:Single-Page-Webanwendung, die mit all ihren „Seiten“ vollständig geladen wird. Unnötiges erneutes Laden der Web-Anwendung muss also vermieden werden, eine Navigation via URL soll aber trotzdem funktionieren.

Eine klassische aber simple Lösung sind anchors, also eine Fortsetzung des URL-Pfads hinter einem Nummernzeichen (hash). Diese HashLocationStrategy für Single-Page-Anwendungen wird mit der Einstellung {useHash: true} bei Einrichtung des Hauptmoduls eingestellt (siehe app.module.ts Code oben).

Bei Ausführung der App erscheinen dann die in der Routes-Tabelle angegebenen Pfade (path) stets hinter einem Hash, sodass der Browser keine Anstalten macht, die Seite neu zu laden.

Ansonsten kann man in der Tabelle Pfade umleiten (redirectTo) oder Komponenten angeben, die als Seite oder Seitenbereich angezeigt und ausgeführt werden sollen. Das RoutingModule kann aber noch viel mehr, ich verweise hier aber auf die Dokumentation, weil SynthCommander nicht mehr braucht, jetzt aber klar sein sollte, wie man in einer Angular-Anwendung zwischen Seiten navigieren kann.

SynthCommander's Backend starten

Bevor ich auf die Services in SynthCommander eingehen kann, muss ich erst auf das „Backend“, also den Serverteil von SynthCommander eingehen, auf welchem zwei dieser Services aufbauen.

Weil dem Browser der direkte Zugriff auf lokale Dateien verwehrt ist und man auch eine Single-Page-Anwendung wie SynthCommander über eine HTML-Seite laden muss, braucht man einen Application-Server, der das alles dem Browser und der App bereitstellt. Hier gibt es zum Glück das bereits erwähnte und auch von Angular CLI genutzte :de:Node.js Framework, für das neben vielen „Middleware“-Plugins auch das Webframework :de:Express.js gehört.

Im Verzeichnis server des SynthCommmander-Quellcodes befinden sich folgende Dateien:

Datei Aufgabe
package.json Node.js / NPM Paketdatei
package-lock.json NPM Paket-Versionsfestlegung
index.js Quellcode des Application Servers
public hier alles aus dem „dist“ Verzeichnis reinkopieren

Wenn man mit dem Angular-Befehl ng build den SynthCommander-Quellcode übersetzt, legt die Angular CLI die übersetzten „Chunk“-Dateien im Unterverzeichnis dist ab. Darunter wird von der Angular CLI auch der Inhalt des Verzeichnisses src/assets kopiert. Dies sind die bekannten Synthesizer-Modelldaten, die auch durch den Application-Server index.js ausgebreitet werden müssen. Der komplette Inhalt von dist muss für den Application-Server in das Verzeichnis /server/public kopiert werden - oder man setzt unter Linux dort den entsprechenden symbolischen Link, sodass der Server direkten Zugriff auf den unter dist/SynthCommander liegenden Anwendungscode hat:

~/projects/SynthCommander/server$ ln -s ../../dist/SynthCommander public

Installation und Start des Servers ist dann ganz einfach:

kzerbe@FX505DY:~/projects/SynthCommander/server$ 
kzerbe@FX505DY:~/projects/SynthCommander/server$ npm install
npm WARN posttest@0.3.0 No repository field.
 
audited 85 packages in 0.904s
kzerbe@FX505DY:~/projects/SynthCommander/server$ npm start
 
> posttest@0.3.0 start /home/kzerbe/projects/SynthCommander/server
> node index.js
 
started server at http://localhost:8008

Wenn man nun die URL http://localhost:8008 im Browser öffent, sollte SynthCommander ganz normal erscheinen und evtl. erstellte Synthesizer-Patches im Verzeichnis server/patches ablegen.

index.js: Backend Quellcode

Dank Node.js, express.js und body-parser.js ist es extrem einfach den benötigten Webserver zu bauen. Die „Middleware“ express.static erledigt die Auslieferung aller statischen Dateien - also die ganzen von Angular CLI erstellten SynthCommander Frontend- Dateien bei Zugriff auf das public- Verzeichnis.

Für Webservice- Requests wird der URL-Pfad /api reserviert und folgende Requests werden behandelt:

Request URLParameterFunktionErgebnis
get /api/listmodelskeinehole Modell-ListeJSON Array mit Modell-Dateinamen
get /api/model?model=Modell Dateinamelies Synthesizer ModelJSON Synth-Definition
get /api/listkeinehole Patches-ListeJSON Array mit Patchnamen
get /api/load?name=patchnamelies Patchdaten via PatchnamePatch in JSON Format
post /api/storeJSON patch in Request-Bodyspeichere Patch in Dateistatus

Die Server-Implementierung

const port = 8008; // server's port
 
const express = require('express');  // express Web-framework
const bodyParser = require('body-parser'); // parse query strings and HTTP body
const fs = require('file-system'); // file access
const YAML = require('yaml');  // YAML file parser
 
const app = express();
 
app.use(express.static(__dirname + '/public'));  // static file middleware
app.use(bodyParser.urlencoded({extended: false})); //
app.use(bodyParser.json()); // support JSON requests/responds
 
const patchDir = __dirname + '/patches';          // patches folder
const modelDir = __dirname + '/public/assets';    // synthmodel folder
 
fs.mkdir(patchDir); // make sure patches folder exists
 
// request list of patchfiles
app.get('/api/list', (request, response) => {
  fs.readdir(patchDir, (err, files) => {
    if (err) {
      response.send(`patch listing failed: ${err.message}`)
    }
    const patches = files.map(fn => { // strip  file extension
      return fn.substring(0, fn.lastIndexOf('.'))
    });
 
    response.send(JSON.stringify(patches)); // respond patchnames as JSON array
  });
});
 
// request patch as /api/load?name=patchname
app.get('/api/load', (request, response) => {
  const patchname = request.query.name;
 
  if (!patchname) {
    response.status(404).send('patch name missing');
    return;
  }
 
  // check for patch file availabilty
  const filename = `${patchDir}/${patchname}.json`;
  fs.readFile(filename, (err, data) => {
    if (err) {
      response.status(404).send('patch file not found');
      return;
    }
    response.status(200).send(data);
  });
});
 
// post patch where request body contains JSON patch data
app.post('/api/store', (request, response) => {
  let msg = 'Ok';
  const patchname = request.body.patchname; // field "patchname" should contain patchname
  if (!!patchname) {
    const filename = `${patchDir}/${patchname}.json`; // build patch file name
    const patch = JSON.stringify(request.body);
    fs.writeFile(filename, patch, (err) => { // store patch file
      if (err) {
        msg = `can't store patch: ${err.message}`;
      }
    });
  } else {
    msg = 'patchname is missing';
  }
  response.status(200).send(`{"status": "${msg}"}`)
});
 
// request list of synthesizer model files
app.get('/api/listmodels', (request, response) => {
  fs.readdir(modelDir, (err, files) => {
    // respond array of filenames of type .json or .yaml
    response.status(200).send(files.filter(name => name.endsWith('.yaml') || name.endsWith('json')));
  });
});
 
// request synthesizer model data as /api/model?model=modelname.yaml
app.get('/api/model', (request, response) => {
  const modelname = request.query.model;
  if (!modelname) {
    response.status(404).send('missing model name');
    return;
  }
 
  let isYaml = modelname.endsWith('.yaml');
  filename = `${modelDir}/${modelname}`
 
  // check for file availability
  if (!fs.existsSync(filename)) {
    response.status(404).send('model file not found');
    return;
  }
 
  let jsdata;
  if (isYaml) { // convert YAML to json
    let data = fs.readFileSync(filename, 'utf-8');
    jsdata =YAML.parse(data);
  } else { // read as JSON
    let data = fs.readFileSync(filename, 'utf-8');
    jsdata = JSON.parse(data);
  }
  // send synth model as JSON
  response.status(200).send(jsdata);
});
 
// start server
app.listen(port, () => {
  console.log(`started server at http://localhost:${port}`);
});

Die Services von SynthCommander

SynthCommander benötigt die folgenden Services, um zu funktionieren:

Service Funktion Zugriff
SynthModelService Datenbank bekannter Synthesizer Auflisten und Lesen nach Aufruf
PatchfileService Speicher für Synthesizer Einstellungen Auflisten, Lesen und Speichern nach Aufruf
WebmidiService Austausch von MIDI Controlchange Messages Senden von Controlchange Messages und Eventbehandlung

All diese Dienste sind asynchron zu behandeln, denn SynthModelService und PatchfileService benötigen einen Webservice, der dem Browser nur über eine Netzverbindung mit unbekannter Latenz erreichbar ist (etwa Internet-Verbindung) und der WebmidiService feuert auch ohne Aufruf Events, wenn ein Regler eines Synthesizers bewegt wird.

Einen Zugang zu lokalen Dateien (auf dem Rechner, auf welchem der Browser läuft) wird von Browsern seit den unseligen Zeiten von Microsoft's „Virenmutterschiff“ Internet Explorer :de:ActiveX nicht mehr erlaubt - man könnte SynthCommander höchstens noch mit dem Framework Electron zu einer nativen Desktop-Anwendung umschreiben.

Aber Angular bietet mit den Observables ja eine recht bequeme Art, asynchronen Datentransfer zu realisieren. Das ist freilich nicht ohne Tücken, denn besonders beim Start der Anwendung ist ja unklar, nach welcher Zeit angeforderte Daten stabil vorliegen - besonders wenn diese Daten voneinander abhängen.

Observables liefern Daten nur an zuvor per subscribe angemeldete Abonnenten. Wer „zu spät“ abonniert, den straft Angular mit der Nichtauslieferung der Daten und die App wartet wie Godot in alle Ewigkeit. Das einfache Observables nur „abgeschlossen“ Daten liefern und dann „schließen“, macht es nicht einfacher.

Einen Ausweg bieten Subjects, die in einer Art „Gedächtnis“ gesammelte Datenströme Neuabonnenten von Anfang an liefern und geöffnet bleiben, um Abonnenten weitere Daten zusenden zu können. Die meisten Service-Funktionen in SynthCommander verwenden daher die Klasse BehaviorSubject, die man zwar mit einem Startwert initialisieren muss, aber dann wiederholt mit einer next- Funktion abgeschickte Daten an alle Abonnenten weiterleitet.

Zum Glück tritt das sonst eher häufige Problem mehrerer voneinander zeitlich bzw. kausal abhängiger Datenströme bei SynthCommander nicht auf - da liegt aber gerade die besondere Stärke des mächtigen in Angular integrierten RxJS Frameworks. Neben den Grundklassen wie Observables und Subjects bietet RxJS eine große Zahl von Operatoren zur Umwandlung und Verknüpfung asynchroner Datenströme. Das sprengt aber den Rahmen dieses Angular Einführungsartikels. Sobald man aber anfängt, Subscriptions per asynchronen Funktionen zu schachteln, sollte man dies als Irrweg erkennen und sich mit RxJS eingehender beschäftigen und dort geeignete Operatoren suchen.

SynthModelService

Der SynthmodelService definiert zwei TypeScript Interfaces zur Beschreibung der Controlchange-Messages, über die man einen Synthesizer zu tollen Klängen veranlassen kann. Der Klang/Sound eines Synthesizers ist durch sehr viele Einstellungen von Oszillatoren, Filtern, Hüllkurven-Generatoren und Effekten wie Hall oder Echo mittels solcher Controlchange-Messages bestimmt. Diese Messages gibt es zwar auch für konventionelle Instrumente wie Pedale bei Klavieren oder Gitarrenverstärkern, bei Synthesizern können es aber einige Dutzend (MIDI-Protokoll begrenzt max. 127) sein. Dabei gehören oft 2-5 Einstellungen zu einer Baugruppe - ein ADSR- Hüllkurvengenerator braucht also mindestens die vier Einstellungen attack, decay, sustain und release, um die Hüllkurve eines Tons zu beschreiben.

Das Interface ICCMessageInterface beschreibt einen einstellbaren Wert mit dessen MIDI-Nummer key, einem Anzeigenamen attr und dem einzustellenden Wert value (eine Zahl 0..127). Eine optionale idemId dient SynthCommander zur Verknüpfung mit einem Werte-Array, das die Angular-Datenbindung einfacher macht, denn es besteht eine direkte Datenverbindung zwischen der MIDI-Schnittstelle und auf der Seite angezeigten Werten.

Das Interface ICCGroupInterface fasst Messages der gleichen Baugruppe (z.B. Hüllkurvengenerator) zu einer Anzeigegruppe mit übergreifenden Namen zusammen.

TypeScript Interfaces sind nicht wie bei anderen Programmiersprachen Funktionsgruppen, sondern ermöglichen statische Typisierung von Daten. So werden aus eher schwach typisierten JSON-Daten statisch typisierte Container, die zu statisch typisierten TypeScript-Objekten kompatibel sind. Das unterstützen Angular's eingebaute Services wie der HttpClient auch mit Generic-Versionen z.B.von get und post:

  this.http.get<ICCGroupInterface[]>('api/model', {params: params})

Die bei *get* gelieferte JSON-Struktur wird also zu einem typensicheren ICCGroupInterface-Array.

Sicher noch etwas schwer zu verstehen ist der Sinn hinter folgendem Codestück:

   let itemId = 0;
   // create Angular bindable value container
   for (let group of model) {
     for (let attr of group.ccm) {
       ccAttr[itemId] = attr;
       attr.itemId = itemId++;
     }
   }

Das Array ccAttr vereinfacht die Datenverbindung zwischen der gruppierten Abbildung der Control-Change-Messages bei der Bedienoberfläche und Valueholdern die MIDI-Daten mit Bedienoberfläche synchronisieren.

Wird jedenfalls ein anderes Synthesizer Modell geladen, erhalten per BehaviorSubject.next() alle abonnierenden Nutzer von Synthesizer-Modellen die neu ausgewählte Variante des Modells (model) und einen passenden Valueholder (controls) für die direkte MIDI- Anbindung. Dieser Mechanismus wird deutlicher wenn man sich später die Verwendung des SynthmodelServices durch die Komponenten anschaut. Bitte jetzt nicht zu lange darüber nachgrübeln.

Der vollständige Quellcode des SynthmodelServices folgt:

import {Injectable} from "@angular/core";
import {HttpClient, HttpParams} from "@angular/common/http";
import {Observable, BehaviorSubject} from "rxjs";
 
// extended MIDI control change message (CC) content
export interface ICCMessageInterface {
  itemId?: number; // object id
  attr: string;    // control name
  key: number;     // MIDI control number
  value: number;   // MIDI control value
}
 
// group of related CCs (e.g. any ADSR, LFO or REVERB CCs
export interface ICCGroupInterface {
  id: string;      // unique CC group short name/id
  name: string;    // CC group print name
  ccm: ICCMessageInterface[];  // all CCs of that group
}
 
@Injectable({
  providedIn: 'root'
})
export class SynthmodelService {
  // model view data (for UI)
  model$ = new BehaviorSubject<ICCGroupInterface[]>(null);
 
  // valuesets for all CCs
  controls$ = new BehaviorSubject<any>(null);
 
  // needing Webservice API
  constructor(private http: HttpClient) {
  }
 
  // allow subscription of model file list
  listModels(): Observable<string[]> {
    return this.http.get<string[]>('api/listmodels');
  }
 
  // load model with known filename
  loadModel(synthmodel: string): Observable<any> {
    const params = new HttpParams().set('model', synthmodel);
    let ccAttr = {};
 
    // subscribe for receiving model file
    this.http.get<ICCGroupInterface[]>('api/model', {params: params})
        .subscribe(model => {
      this.model$.next(model); // notify model subscribers
      let itemId = 0;
      // create Angular bindable value container
      for (let group of model) {
        for (let attr of group.ccm) {
          ccAttr[itemId] = attr;
          attr.itemId = itemId++;
        }
      }
      this.controls$.next(ccAttr); // notify value container update
    });
 
    return this.controls$;
  }
}

PatchfileService

Der PatchfileService gestaltet sich recht einfach und kommt ganz ohne Subjects aus, weil nutzende Komponenten sich direkt auf den Webservice abonnieren, d.h. das vom HttpClient bereits gelieferte Observable wird einfach nur an die Komponente durchgereicht:

import {Injectable} from "@angular/core";
import {Observable} from "rxjs";
import {HttpClient, HttpParams} from "@angular/common/http";
import {ICCMessageInterface} from "./synthmodel.service";
 
 
// named set of CC Messages
export interface IPatchDefinition {
    patchname: string;
    data: ICCMessageInterface[];
}
 
@Injectable({
    providedIn: 'root'
})
export class PatchfileService {
    constructor(private http: HttpClient) {
    }
 
    //let you subscribe for array of patchnames
    getPatchfiles():Observable<string[]> {
        return this.http.get<string[]>('api/list')
    }
 
    //let you store current settings as patch
    savePatchFile(patch: IPatchDefinition) {
        this.http.post<IPatchDefinition>('api/store', patch);
    }
 
    //let you subscribe for patch by patchname
    loadPatchFile(patchname: string): Observable<IPatchDefinition> {
        let params = new HttpParams().set('name', patchname);
        return this.http.get<IPatchDefinition>('api/load', {params: params});
    }
}

Das Interface IPatchDefinition beinhaltet einfach ein (hier nicht gruppiertes) Array von Control-Change-Messages und gibt ihm einen patchname. Das Feld patchname wird ja vom Webservice als Grundlage für einen Dateinamen auf dem Server genutzt, sodass man solche Synthesizer-Konfigurationen über diesen Namen identfizieren kann.

Eine Fehlerbehandlung ist hier noch unnötig, denn mögliche Probleme können auch erst im subscribe behandelt werden und werden hier in die Komponente verlagert - was eigentlich kein guter Stil ist, aber ich möchte dies Tutorial möglichst einfach gestalten.

WebmidiService

Der WebmidiService ist eigentlich nur ein kleiner „Wrapper“ für Angular um benötigte Teile der sehr mächtigen webmidi-Library. Der Constructor fordert per WebMidi.enable() erst mal eine MIDI-Verbindung an, was aus verschiedenen Gründen fehlschlagen kann:

  1. der Browser hat keine WebMidi Unterstützung
  2. der Benutzer verweigert den Zugriff auf WebMidi (bei neueren Browsern erscheint ein Dialog)
  3. MIDI wird bereits von einer anderen Anwendung genutzt und unterstützt nicht mehrere Nutzer

Im Fehlerfall wird eine entsprechende Nachricht (per error$ Observable) an Abonnenten geschickt, im Erfolgsfall erhalten Abonnenten eine Liste von verfügbaren Input- und Output- Geräten über die entsprechenden input$- und output$-Observables. Außerdem können Subscriber ein controlChanges-Observable abonnieren und werden dann über alle Änderungen an Control-Einstellern (Drehknöpfe, Schieber etc.) des ausgewählten MIDI-Geräts informiert.

Mit den Methoden setInput und setOutput kann ein Aufrufer des WebmidiService ein MIDI-Gerät auswählen, auf das sich die zu beobachtenden oder zu sendenden Control-Change-Messages (CCs) beziehen.

Mit der Methode setControl können dann CCs per MIDI ans Gerät gesendet werden.

Die Methoden playNote und stopNote erlauben die Wiedergabe von Testtönen, um die Wirkung der CCs hören zu können.

Die Methode handleEvents ist „private“, wird also nur intern verwendet, um den Versand von MIDI-Input Änderungen zu veranlassen.

import {Injectable} from "@angular/core";
import WebMidi, {Input, Output} from 'webmidi';
import {BehaviorSubject} from "rxjs";
 
const noOutputErr = 'please select device first';
 
// data of MIDI input change
export class ControlChangeMessage {
  control: number;
  value: number;
}
 
@Injectable({
  providedIn: 'root'
})
export class WebmidiService {
  error$ = new BehaviorSubject<string>('');       // error message stream
  inputs$ = new BehaviorSubject<Input[]>(null);   // MIDI inputs
  outputs$ = new BehaviorSubject<Output[]>(null); // MIDI outputs
 
  // MIDI input change stream
  controlChanges$ = new BehaviorSubject<ControlChangeMessage>(null);
 
  input: Input = null;      // selected  MIDI input device
  output: Output = null;    // selected  MIDI output device
 
  constructor() {
    // connect to WebMidi API
    WebMidi.enable(err => {
      let msg = '';
      if (err) {
        msg = err.message; // no midi available error
      } else if (!WebMidi.outputs.length) {
        msg = 'no MIDI out devices attached.';
      } else {
        // notify subscribers
        this.inputs$.next(WebMidi.inputs);
        this.outputs$.next(WebMidi.outputs);
        if(WebMidi.outputs.length == 1) {
          this.setOutput(0); // set default output device
        }
        this.handleEvents(); // monitor MIDI input
        msg = ''; // no error
      }
      this.error$.next(msg); // notify error
    })
  }
 
  // select input device via input$ index
  setInput(index: number) {
    this.inputs$.subscribe(inputs => {
      this.input = inputs[index];
      this.handleEvents();
    });
  }
 
  // select output device via output$ index
  setOutput(index: number) {
    this.outputs$.subscribe(outputs => {
      this.output = outputs[index];
    });
  }
 
  // set CC with MIDI number key to value
  setControl(key: number, value: number) {
    if (!this.output) {
      this.error$.next(noOutputErr);
      return
    }
    try {
      this.output.sendControlChange(key, value, 1);
    } catch (e) {
      this.error$.next(e.message);
    }
  }
 
  // play test node MIDI note number note
  playNote(note: number) {
    if (!this.output) {
      this.error$.next(noOutputErr);
      return
    }
    try {
      this.output.playNote(note, 1);
    } catch(e) {
      this.error$.next(e.message);
    }
  }
 
  // mute note
  stopNote(note: number) {
    if (!this.output) {
      this.error$.next(noOutputErr);
      return
    }
    try {
      this.output.stopNote(note, 1);
    } catch(e) {
      this.error$.next(e.message);
    }
  }
 
  // monitor MIDI input for control changes
  handleEvents() {
    if (!this.input) {
      this.error$.next(noOutputErr);
      return
    }
    this.input.addListener('controlchange', 1, (e) => {
      let control = e.controller.number;
      let value = e.value;
 
      // notify subscribers
      this.controlChanges$.next({control: control, value: value});
    });
  }
}

Es folgt die Beschreibung der Benutzerschnittstellen-Komponenten in Kapitel 3 dieses Tutorials.

This website uses cookies. By using the website, you agree with storing cookies on your computer. Also, you acknowledge that you have read and understand our Privacy Policy. If you do not agree, please leave the website.

Weitere Information