SynthCommander Hauptseite
SynthCommander besitzt die in app.module.ts eingetragene Hauptseite app.component.ts. Diese enthält in ihrem HTML-Template eine von Angular's Router-Infrastruktur bereitgestellte Komponente <router-outlet>, welche der Router durch eine per Route der aktuellen URL zugeordnete Komponente ersetzt. Ändert sich die URL durch Anklicken eines Links oder andere Benutzeraktion (auch Adressleiste), wird der Inhalt des <router-outlet> also ausgetauscht.
Das Template stellt lediglich Titel, Aufwahlfelder für MIDI-Gerät und Synthesizer-Modell und ein Navigationsmenü dar. Für die Listen der Midi-Geräte und Synthesizer-Modelle liefern die Services WebmidiService und SynthmodelService Observables, die aber gar nicht abonniert werden, denn hier kommt die Pipe async zum Einsatz:
<select id="devicelist" class="form-control" (change)="onOutputChange($event.target)"> <option *ngFor="let output of outputs$ | async; let idx=index" value={{idx}}>{{output.name}}</option> </select>
Pipes können für Werte und Ausdrücke in Templates benutzt werden, um Daten umzuwandeln oder zu filtern. Angular bietet eine größere Zahl davon, man kann aber natürlich auch eigene entwickeln. Die Pipe async abonniert das vor dem „|“- Zeichen stehende Observable und kann somit den angezeigten Inhalt wiederholt aktualisieren. Eine andere nützliche Pipe ist json, besonders wenn man bei der Fehlersuche sehen will, ob bei einem Teil der Bedienoberfläche die erwarteten Daten auch gebunden sind.
Das Navigationsmenü ist als simple Bootstrap 4 Navbar aufgebaut, hier werden aber die Angular Direktiven routerlink und routerlinkAktive genutzt, um die Navigation über Angular's Router zu steuern:
<li class="nav-item"> <a class="nav-link" routerLink="output" routerLinkActive="active">Control MIDI output</a> </li>
Für routerlink werden die in der Routes-Tabelle registrierten Pfade verwendet, routerLinkActive sorgt für eine Markierung des ausgewählten Menüpunkts in der Menüleiste. Der bei routerLinkActive zugewiesene Wert ist ein CSS-Klassenname, der die Darstellung der aktiven Menüoption beeinflusst.
Wenn das Abonnement des error$- Observables einen nichtleeren String liefert, wird dieser als Fehlermeldung angezeigt. Gesteuert wird das über eine ngIf- Direktive, wodurch ein Tag nur gerendert wird, wenn ngIf wahr ist:
<div *ngIf="error" class="alert alert-danger">{{error}}</div>
Im TypeScript-Code der AppComponent werden der WebmidiService und der SynthmodelService injected, um die Observables von Output-Geräteliste, Synthesizer-Modell-Liste und Fehler-Datenstrom Properties von AppComponent zuzuweisen, um für das Template erreichbar zu sein - auch ohne Subscription.
Dann sind da noch (change)-Eventhandler für die beiden Auswahlfelder. Bei einer Auswahl werden durch Aufruf von setInput, setOutput bzw. loadModel für alle Komponenten Geräteeinstellung und Synthesizermodell verändert, denn das führt zur Aktualisierung aller Komponenten, die Modell- oder Output-Gerät- Abonnements eingerichtet haben.
Deshalb stellen bei Auswahl eines anderen Synthesizer-Models die Komponenten InputComponent und OutputComponent automatisch die Inhalte dar, welche sie durch ihr Abonnement erhalten. Hier zeigt sich der Vorteil von Observable-Datenströmen zur Datenbindung der Templates.
Es folgt der gesamte Code der AppComponent - Hauptseite:
import {Component, OnInit} from '@angular/core'; import {Observable} from "rxjs"; import {WebmidiService} from "./webmidi.service"; import {Output} from "webmidi"; import {SynthmodelService} from "./synthmodel.service"; @Component({ selector: 'app-root', template: ` <div class="container"> <h1>{{title}}</h1> <div class="form"> <div class="form-group col-4"> <label for="devicelist">select MIDI output device</label> <select id="devicelist" class="form-control" (change)="onOutputChange($event.target)"> <option *ngFor="let output of outputs$ | async; let idx=index" value={{idx}}>{{output.name}}</option> </select> </div> <div class="form-group col-4"> <label for="devicemodel">synthesizer model</label> <select id="devicemodel" class="form-control" (change)="onModelChange($event.target)"> <option [value]="null">choose model</option> <option *ngFor="let model of models$ | async" value={{model}}>{{model}}</option> </select> </div> </div> <div *ngIf="error" class="alert alert-danger">{{error}}</div> <nav class="navbar navbar-expand-sm bg-light"> <ul class="navbar-nav"> <li class="nav-item"> <a class="nav-link" routerLink="info" routerLinkActive="active">Info</a> </li> <li class="nav-item"> <a class="nav-link" routerLink="input" routerLinkActive="active">Monitor MIDI input</a> </li> <li class="nav-item"> <a class="nav-link" routerLink="output" routerLinkActive="active">Control MIDI output</a> </li> </ul> </nav> <router-outlet></router-outlet> </div> `, styles: [] }) export class AppComponent implements OnInit { title = 'Synth Commander'; outputs$: Observable<Output[]>; models$: Observable<string[]>; error: string; constructor(private midiService: WebmidiService, private synthmodelService: SynthmodelService) { } ngOnInit(): void { this.outputs$ = this.midiService.outputs$; this.models$ = this.synthmodelService.listModels(); this.midiService.error$.subscribe(err => this.error = err); } onOutputChange(target: any) { let idx = parseInt(target.value, 10); this.midiService.setOutput(idx); this.midiService.setInput(idx); } onModelChange(target: any) { const model = (target as HTMLInputElement).value; this.synthmodelService.loadModel(model); } }
OutputComponent Unterseite
Die Unterseite OutputComponent wird vom Router anstelle des router-outlet angezeigt, wenn zur Url output navigiert wird. OutputComponent benötigt den SynthmodelService, um Modell-Änderungen zu abonnieren, den PatchfileService, um Patches aufzulisten, zu laden und zu speichern, steuert den aktuellen Synthesizer mit Control Changes (CC) und gibt Testtöne aus.
Die Direktive SliderMoveDirective wurde bereits im letzten Kapitel vorgestellt und wird bei der OutputComponent für alle Slider (input type=range„) zur Anzeige der Einstellwerte in einem nachfolgendem span verwendet.
Das disabled Attribut des Mute-Buttons wird über das playing-Property so gesteuert, dass er nur funktioniert, wenn gerade ein Testton abgespielt wird.
Weil die Datenbindung der gruppiert dargestellten Control-Change -Slider etwas kompliziert ist, gibt es das Array ccAttr, welches die zu bindenden Controls in einem simplen Array abbildet. Die Bildung dessen Index hat ja bereits bei der Beschreibung des SynthModelService und dem Sinn des Felds itemId Rätsel aufgegeben. Jedenfalls „kennt“ so jedes Control seinen Index in dem zur Datenbindung genutzten ValueHolder ebenso wie die seine MIDI-Nummer und den Einstellwert. Das geht vielleicht auch eleganter - vielleicht kann mir ein Leser ja auch eine bessere Lösung dieses Problems vorschlagen. Die Hilfsfunktionen itemIndex und item werden so auch noch benötigt, jedem Control seinen richtigen ValueHolder zuzuordnen.
Die Eventhandler onNoteChange und onMute sind simple Wrapper für von der webmidi-Library bereit gestellten Funktionen, der onChangeControl -Handler nutzt ebenfalls eine Funktion dieser Library (setControl), die natürlich auch vom Handler onLoadPatch benötigt wird, um beim Laden einer Patchdatei sämtliche Controls auf die gespeicherten Werte einzustellen.
Das beim Laden von Patches dort nicht gespeicherte Controls auf 0 gesetzt werden, ist vielleicht ein Problem. Beim Speichern von Patches werden Controls, deren Regler Nullstellung haben zwar nicht mitgespeichert, aber auf 0 gesetzte Controls können bei manchen Synthesizern ungültige Konfigurationen sein oder jeglichen Sound abschalten. Hier überlasse ich aber den Leser, vielleicht mal mit anderen Speicher-Strategien zu spielen.
Die Konfiguration von Sounds über Control-Changes ist vielleicht für viele Synthesizer ohnehin nicht optimal. Bei vielen Controls entstehen auch lange Midi-Sequenzen, deren Übertragung bei der niedrigen Bitrate so lange dauert, dass es während des Spielens zu störenden Latenzen führt. Auch sind für manchen FM-Synthesizer (Yamaha DX7, Korg Volca FM, etc.) „nur“ 127 Controls zu wenig. Bei diesen Synthesizern werden Pattern mit proprietären Sysex- Messages übertragen, was natürlich einen „generischen“ Ansatz für fast beliebige Synthesizer wie bei SynthCommander unmöglich macht.
Aber zumindest als Angular-Tutorial mit hohem Unterhaltungswert und viel Coolness ist SynthCommander allemal geeignet.
Der vollständige OutputComponent Code folgt hier:
import {Component, Directive, HostListener, OnInit} from '@angular/core'; import {WebmidiService} from "./webmidi.service"; import {PatchfileService} from "./patchfile.service"; import {ICCGroupInterface, ICCMessageInterface, SynthmodelService} from "./synthmodel.service"; // update textContent of next sibling with range input value @Directive({ selector: '[slidermove]' }) export class SliderMoveDirective { @HostListener('input', ['$event']) onSliderMove($event) { let target = $event.target.nextSibling; target.textContent = $event.target.value; } } @Component({ selector: 'app-output', template: ` <div *ngIf="synthModel"> <!-- test tone slider --> <h4>Tone selection</h4> <div class="row"> <input type="range" class="col-9" min="46" max="92" [value]="testNote" (input)="onNoteChange($event)" slidermove> <span class="col-1">{{testNote}}</span> <button [disabled]="!playing" class="btn btn-primary col-2" (click)="onMute()">mute</button> </div> <hr/> <!-- storing and loading patches --> <h4>Patch Storage</h4> <div class="form"> <div class="form-group row"> <label class="col-form-label col-2 text-right" for="patchname">new patchname</label> <input type="text" class="form-control col-4 mr-2" id="patchname" (input)="updatePatchName($event)"/> <button class="btn btn-primary col-2 btn-sm" (click)="onSavePatch()">Save</button> </div> <div class="form-group row"> <label class="col-form-label col-2 text-right" for="selectpatch">select patch</label> <select class="form-control col-4 mr-2" id="selectpatch" (change)="onSelectPatchname($event)"> <option *ngFor="let patch of patchfiles" [value]="patch">{{patch}}</option> </select> <button class="btn btn-primary col-2" (click)="onloadPatch()">Load</button> </div> </div> <hr/> <!-- MIDI control change sliders --> <h4>Control Change</h4> <div *ngFor="let ccgroup of synthModel; let ccIdx1=index"> <h5>{{synthModel[ccIdx1].name}}</h5> <div *ngFor="let ccAttr of synthModel[ccIdx1].ccm; let ccIdx2=index"> <label for=cc{{ccIdx2}} class="col-2" >{{item(ccIdx1, ccIdx2).attr}}</label> <input type="range" id=rv{{idx2}} class="col-4" min="0" max="127" [value]="item(ccIdx1, ccIdx2).value" (input)="onChangeControl(itemIndex(ccIdx1, ccIdx2), $event)" slidermove> <span [textContent]="item(ccIdx1, ccIdx2).value" class="col-1">0</span> </div> </div> </div> `, styles: ['text-right {justify-content: right}'] }) export class OutputComponent implements OnInit { synthModel: ICCGroupInterface[] = []; // current synth model ccAttr: any; // CC value mapping testNote = 46; // MIDI test note value playing = false; // test note playing ? patchfiles: string[] = []; // patch file names currentPatch = ''; patchname = ''; constructor(private midiService: WebmidiService, private patchService: PatchfileService, private synthmodelService: SynthmodelService) { } ngOnInit() { // subscribe patch name list this.patchService.getPatchfiles().subscribe(patchnames => { this.patchfiles = patchnames; if (this.patchfiles.length && !this.currentPatch) { this.currentPatch = this.patchfiles[0]; } }); // subscribe control change model & value mapping this.synthmodelService.model$.subscribe(model => this.synthModel = model); this.synthmodelService.controls$.subscribe(controls => this.ccAttr = controls); } // helper to get value mapping index itemIndex(groupIdx: number, attrIndex: number): number { return this.synthModel[groupIdx].ccm[attrIndex].itemId; } // helper to get mapped value holder item(groupIdx: number, attrIndex: number): ICCMessageInterface { return this.ccAttr[this.itemIndex(groupIdx, attrIndex)]; } // test tone slider moved onNoteChange(event: Event) { if (this.playing) { this.midiService.stopNote(this.testNote); } this.testNote = parseInt((event.target as HTMLInputElement).value, 10); this.midiService.playNote(this.testNote); this.playing = true; } // mute button clicked onMute() { if (this.playing) { this.midiService.stopNote(this.testNote); this.playing = false; } } // control slider moved onChangeControl(controlIdx: number, event: Event) { const value = parseInt((event.target as HTMLInputElement).value, 10); const ccm = this.ccAttr[controlIdx]; ccm.value = value; this.midiService.setControl(ccm.key, ccm.value); } // patch name to store changed updatePatchName(event: Event) { this.patchname = (event.target as HTMLInputElement).value; } // patchname selection changed onSelectPatchname(event: Event) { this.currentPatch = (event.target as HTMLSelectElement).value; } // patch file selection committed onloadPatch() { if (!this.currentPatch) { return; } // read new patchfile this.patchService.loadPatchFile(this.currentPatch).subscribe(patch => { const count = Object.keys(this.ccAttr).length; // reset value holders for (let paramIdx = 0; paramIdx < count; ++paramIdx) { this.ccAttr[paramIdx].value = 0; this.midiService.setControl(this.ccAttr[paramIdx].key, 0); } // set loaded control values to UI and MIDI for (let cp of patch.data) { this.ccAttr[cp.itemId] = cp; this.midiService.setControl(cp.key, cp.value); } }); } // save current configuration to patch file onSavePatch() { if (!this.patchname) { return; } let patch = {patchname: this.patchname, data: []}; for (let attr of Object.values(this.ccAttr)) { const att = attr as ICCMessageInterface; if(att.value) { patch.data.push(att); } } this.patchService.savePatchFile(patch); this.patchfiles.push(this.patchname); } }
InputComponent Unterseite
Die Unterseite InputComponent wird vom Router anstelle des router-outlet angezeigt, wenn zur URL input navigiert wird.
Der Zweck dieser Komponente ist vielleicht nicht gleich ersichtlich - sie zeigt nur die Einstellwerte von Controls an, die man am Midi Input - Gerät (evtl. der gleiche Synthesizer) verstellt. Entstanden ist das für den Korg NTS-1 Digital Kit, mit dem man zwar wunderbar jammen kann - aber es schwer hat einen einmal gefundenen coolen Sound jemals wieder zu erreichen. Hätte man die Midi Einstellungen der Controls „mitgeschrieben“, die der NTS-1 aber (etwa im Gegensatz zum Volca FM) gar nicht anzeigen kann, könnte man sie wiederholen oder in einer DAW benutzen.
Genau das ermöglicht nun diese Seite: Alle für das Synthesizer-Modell (in der YAML-Datei) definierten Controls werden angezeigt und die Einstellwerte werden beim Betätigen der Bedienelemente des Synthesizers auf der Seite angezeigt.
Das ermöglicht das controlChange$ - Observable des WebMidiService. Auch diese Seite benutzt das schon bei der OutputComponent vorgestellte ValueHolder- Konzept und schon im OnInit -Hook der Komponente werden die aktuellen Einstellwerte angenommen und auf die entsprechend des Synthmodel angeordneten Felder verteilt.
import {ChangeDetectorRef, Component, DoCheck, OnInit} from "@angular/core"; import {WebmidiService} from "./webmidi.service"; import {ICCGroupInterface, ICCMessageInterface, SynthmodelService} from "./synthmodel.service"; @Component({ selector: 'app-input', template: ` <div> <hr> <div *ngIf="synthModel"> <h3>MIDI Input Monitor</h3> <br> <h4>Control Change Messages</h4> </div> <!-- display grouped midi control changes --> <div class="list-group d-flex flex-row"> <div *ngFor="let ccgroup of synthModel; let ccIdx1=index" class="p-2"> <h5>{{synthModel[ccIdx1].name}}</h5> <div *ngFor="let ccAttr of synthModel[ccIdx1].ccm; let ccIdx2=index"> <span>{{item(ccIdx1, ccIdx2).attr}}</span> <span>{{item(ccIdx1, ccIdx2).value}}</span> </div> </div> </div> </div> `, styles: [] }) export class InputComponent implements OnInit, DoCheck { synthModel: ICCGroupInterface[] = []; // current synth model ccAttr: any = []; // control change value mapping constructor(private midiService: WebmidiService, private synthmodelService: SynthmodelService, private cdr: ChangeDetectorRef) { } ngOnInit() { // subscribe for MIDI input control changes this.midiService.controlChanges$.subscribe(chg => { if (!chg) { return; } // update UI with control changes for (let group of this.synthModel) { for (let attr of group.ccm) { if (attr.key === chg.control) { this.ccAttr[attr.itemId].value = chg.value this.cdr.detectChanges(); } } } }); // subscribe control change model & value mapping this.synthmodelService.model$.subscribe(model => this.synthModel = model); this.synthmodelService.controls$.subscribe(controls => this.ccAttr = controls); } // helper to get value mapping index itemIndex(groupIdx: number, attrIndex: number): number { return this.synthModel[groupIdx].ccm[attrIndex].itemId; } // helper to get mapped value holder item(groupIdx: number, attrIndex: number): ICCMessageInterface { return this.ccAttr[this.itemIndex(groupIdx, attrIndex)]; } // forced change detection ngDoCheck() { this.cdr.detectChanges(); } }
Fazit
Ich weiß nicht, ob und wie dies Tutorial jemandem nutzt und ob etwas von dem Spass rüberkommt, den ich bei Schreiben der App und dieses Tutorials hatte - vom Jammen mit meinen drei Korg- Synthesizern NTS-1, Volca Keys und Volca FM mal ganz abgesehen.
Ich würde mich über etwas Feedback bei Facebook oder als Mail an klaus@zerbe.cloud aber sehr freuen.
Auch wer mit elektronischer Musik bzw. Synthesizern gar nichts anfangen kann, kann Teile des Codes als Anregung oder Vorlage nutzen, um etwas ganz anderes (mit oder ohne Midi) damit zu bauen. Deshalb habe ich das Projekt ja auch auf GitHub abgelegt und würde mich auch über Forks aller Art freuen.
Klaus Zerbe in Mainz am 9. Juni 2020