Nachdem das Grundgerüst soweit aufgestellt wurde, war es höchste Zeit für die erste richtige Funktion: Ein Metronom.

Für effektives Üben unvermeidlich und daher äusserst wichtig. Die Implementierung dieser Funktion erwies sich jedoch schwerer als gedacht und wird in Zukunft erneut eine wichtige Rolle spielen. Doch fangen wir bei 0 an.

Ich möchte erstmals auch auf den Code direkt eingehen, mit dem ich das Metronom programmiert habe und die Schwierigkeiten bei einer Entwicklung einer Funktion aufzeigen.

Davor möchte ich erstmalig Screenshots der Apphoven-Alpha App vorstellen.

Derzeit ist die App folgendermassen aufgebaut:

Startbildschirm > Login-Fenster > Homebildschrim > Metronome / (Profile / Settings)

Die Funktionsweise des Metronoms kann man aus dem GIF-Bild entnehmen. Ersichtlich ist ein Slider (der das Intervall in Echtzeit einstellen kann), ein Eingabefeld (zur „manuellen“ Eingabe des Metronoms), ein Ausgabefeld mit der Tempobezeichnung (basierend auf dem aktuellen Intervall-Wert).

Erwähnenswert hierbei ist, dass es keine Rolle spielt wie der Wert verändert wie (Slider oder Eingabefeld). Das vom Nutzer gewünschte Intervall wird direkt in Echtzeit auf alle Elemente übertragen. Wie man das erreicht, werde ich nun erklären.

Wie ist die Benutzeroberfläche (UI) aufgebaut?

Die Benutzeroberfläche besteht derzeit aus einer HTML-Datei/XML-Datei und benötigt (neben der obligatorischen Komponente-Datei) auch eine Pipe.

<ActionBar title="Metronom">
</ActionBar>
<StackLayout>
<Label ="interval"></Label>
<Label text="{{ interval | tempoterm }}"></Label>
<TextField #tfVal [ngModel]="interval" keyboardType="number" (returnPress)="setInterval(tfVal.text)"></TextField>
<GridLayout class="m-15" rows="auto" columns="50 * 50">
    <Label class="h3" text="40" textWrap="true" row="10" col="0"></Label>
    <Slider #slVal minValue="40" maxValue="300" [(ngModel)]="interval" (valueChange)="setInterval(interval)" row="0" col="1"></Slider>
    <Label class="h3" text="300" textWrap="true" row="0" col="2"></Label>
</GridLayout>
<Button (tap)="start()" text="Start"></Button>
<Button (tap)="stop()" text="Stop"></Button>
</StackLayout>

Dieser Code beinhaltet die Benutzeroberfläche und verknüpft Elemente & Events mit Funktionen. Die beiden Buttons (Zeile 12 & 13) hören auf das Event (tap) und lösen somit beim Drücken des Buttons die Funktion start(), bzw. stop() aus. Aus technischer Hinsicht interessant ist sicherlich auch die hervorgehobene Zeile 5. Dies ist die Ausgabe der Bezeichnung für das Tempo (z.B. Allegro, Andante etc.). Die geschweiften Klammern {{ … }} werden für das Einsetzen von Variablen in HTML-Elemente benötigt (Angular2-Basics). Eingesetzt wird jedoch nicht nur die Variable interval, diese durchläuft auch noch die Pipe tempoterm:

@Pipe({name: 'tempoterm'})
export class TempoTermPipe implements PipeTransform {
 transform(interval: number): string {
  switch(true) {
   case (interval < 60): return("Largo"); case(interval >= 60 && interval < 66): return("Larghetto"); case(interval >= 66 && interval < 76): return("Adagio"); case(interval >= 76 && interval < 108): return("Andante"); case(interval >= 108 && interval < 120): return("Moderato"); case(interval >= 120 && interval < 168): return("Allegro"); case(interval >= 168 && interval < 200): return("Presto"); case(interval >= 200 && interval < 208): return("Prestissimo"); case(interval >= 208): return("Prestissimo / Up Tempo");
  default:
   return("Unknown Tempo");
  }
 }
}

Eine Pipe verändert ein Element / Variable / Text in Echtzeit. Die Variable interval (zB. = 120) durchläuft die Pipe und wird auf Zeile 5 vom ursprünglichen Wert 120 auf Allegro gesetzt. Das ist eine äusserst effektive und elegante Art, einen Wert (der ständige geändert werden kann) umzuwandeln (sogar vom Typ number in den Typen string). Generell setzt der Code und die UI auf eine Technik, die sich Two-Way-Databinding nennt (erkennbar in metronome.component.html[(ngModel)]). Die eckigen Klammern stehen für die Ausgabe eines Wertes, die runden für die Eingabe. Nutzt man beide wie in meinem Fall, kann man den Wert gleichzeitig abrufen und verändern. Diese Eigenschaft ist essenziell für die ganze Komponente, da man das Metronom ansonsten immer stoppen und neu starten müsste. Der Weg über Two-Way-Databinding ist für den Nutzer somit deutlich bequemer und schneller: Den Wert verändern (egal ob Eingabefeld oder Slider) und dieser wird direkt in Echtzeit auf alle Elemente (auch durch die Pipe) übernommen.

Wie & wer generiert den Ton?

Das bereits mein erstes Problem so banal sein würde, habe ich wirklich nicht gedacht. Anders als im Web, kann man per HTML ganz einfach Audiodateien und Sounds einbinden, die über das DOM abgespielt werden können. Da ich aber auf eine (für den Endnutzer) native Programmierung setzte habe ich kein DOM, wie man es aus dem Browser kennt. Glücklicherweise konnte ich eine Erweiterung zum vereinfachten Abspielen von kurzen Sounds finden. Pech hatte ich leider nur bei der Einbindung… Wie bei jeder Bibliothek / Erweiterung die man für eine Nativescript App verwendet, muss ein Dependency-Eintrag im Paketverzeichnis der App gesetzt werden:

"dependencies": {
    "@angular/common": "2.1.2",
    "@angular/compiler": "2.1.2",
    ...
    "nativescript-plugin-firebase": "^3.8.4",
    "nativescript-sound": "^1.0.4",
    "nativescript-theme-core": "^0.1.3",
    ...
    "tns-core-modules": "^2.4.0"
  },

Anschliessend muss die App neu kompiliert werden (und ein neuer Build wird erstellt => neue App). Normalerweise wäre man nun fertig mit der Einbindung einer neuen Erweiterung. In diesem Fall musste ich die App jedoch nicht nur von Grund auf kompilieren, sondern die komplette Platform Android löschen und erneut installieren. Auch die Nativescript-Coremodule habe ich alle erneut installieren müssen, da das Plugin sonst ständig einen unbekannten Error ausgab. Wer Nativescript selbst mal ausprobieren will, sollte nicht frustriert sein, wenn eine Erweiterung nicht direkt funktioniert: Plattform (Android und/oder iOS) löschen, neu hinzufügen, Nativescript (und Coremodule) neu installieren & diesen Vorgang evtl. erneut in anderer Reihenfolge wiederholen (durch die vielen Kompilierungsschritte, kann die Reihenfolge des Hinzufügen und Löschens von grosser Bedeutung sein).

Die Metronom Komponente

    // Vorladen der Audiodatei, damit der Sound vor Aktivierung des Metronoms bereit ist.
    public metronome = sound.create("~/pages/metronome/audio/click.mp3");



    // Setzen diverser Variablen (Intervall in BPM, Intervall in Millisekunden und Metronom-Tick-Zähler)
    public interval: number = 120;
    public timer: number;
    public counter: number = 0;



    // Option, die Analyse zu (de)aktivieren, setzen der zu analysierenden Metronom-Ticks
    public devModeNumber = null; // null = deactivated; 1 to x ticks = activated



    // Constructor erstellt Instanz des Providers PerformanceTestService
    constructor(public performanceTester: PerformanceTestService){}



    // Methode, die das Metronom startet
    start(){
        this.stop(); // Laufenden Metronom-Prozess stoppen
        console.log("START: " + this.interval);
        this.tick();
    }



    // Methode, die das Metronom stoppt
    stop() {
        clearTimeout(this.timer);
    }



Ich habe die Funktionsweise der einzelnen Code-Zeilen im oberen Auszug der Komponente bereits mit Kommentaren erklärt. Auf den Constructor, sowie die Methode this.tick() gehe ich nun vertieft ein:

public tick() {
    // Registriere den ersten Zeitstempel, Ausgabe in der Console: true
    this.performanceTester.getTimestamp(1, true);



    // Inhalt if-Schleife im ersten Tick-Durchlauf überspringen (da Zeitstempel 2 noch nicht registriert ist)
    if(this.counter !== 0){
        // Beide Zeitstempel in Array speichern, Console: true
        this.performanceTester.addTimestampToTotal(this.counter, true);
    }



    // Console-Log & Erhöhung (x+1) des aktuellen Ticks
    console.log("Tick: " + this.counter++);



    // Zweiten Zeitstempel registrieren
    this.performanceTester.getTimestamp(2, true);



    // Metronom-Sound ausgeben
    this.metronome.play();



    // Timeout setzen, bis diese Funktion komplett neu durchlaufen wird
    // Dieser Timeout stellt das eigentliche Intervall des Metronoms dar
    this.timer = setTimeout(this.tick.bind(this), 60000/this.interval);



    // Analyse nach X Ticks (= this.devModeNumber) auslösen
    if(this.counter-1 === this.devModeNumber){
        this.stop();
        let theoreticalInterval = 60000/this.interval;



        // Analyse ausgeben
        this.performanceTester.analyze(theoreticalInterval);
    }
}

Der Auszug der Metronom-Komponente stellt die Logik des Metronoms dar. Interessant hierbei: Der Nutzer stellt das Metronom in BPM (Beats per minute) ein, die App benötigt jedoch Werte in Milllisekunden. Daher die Umrechnung (x = Intervall in BPM):

60s * 1000 / x = 60’000 / x

Stark auffallend im Code ist auch der Service this.performanceTester, von welchen im vorher im Constructor eine Instanz für die Komponente erstellt habe. Dieser Service ist weder für den Nutzer von Bedeutung, noch wird er zum Betreiben des Metronoms gebraucht. Er dient mir dazu das tatsächlich ausgeführte Intervall festzustellen. Theoretisch wird natürlich das exakte Intervall angegeben. Zum Beispiel für BPM = 120:

Delay(120BPM) = 60’000ms / 120 = 500ms

Die App wird diesen Idealwert jedoch wohl kaum treffen. Die Frage ist daher nicht ob, sondern wie stark der tatsächliche Wert vom theoretischen abweicht. Ist die Abweichung zu gross, ist das Metronom nicht metrisch und damit unbrauchbar. Zur Analyse habe ich daher einen Service PerformanceTestService geschrieben, den ich nun unter die Lupe nehmen werde:




 // Definieren der Zeitstempel-Variablen, des Zeitstempel-Arrays, des tatsächlichen Intervall-Durchschnitts
 public timestamp1: number;
 public timestamp2: number;
 public timestampTotal: number[] = [0];
 public intervalAverage: number;


 // Methode, die den aktuellen Zeitstempel erfasst
 getTimestamp(num: number, consoleLogValues: boolean){


     if(num === 1) { // Wenn Zeitstempel 1 dann...


         this.timestamp1 = Date.now(); // ... Zeitstempel erfassen (und in Variable this.timestamp1 speichern)
         if(consoleLogValues) { console.log("Timestamp1: " + this.timestamp1) }; // wenn gewünscht, Wert in der Konsole ausgeben


     } else if (num === 2) { // Wenn Zeitstempel 2 dann...


         this.timestamp2 = Date.now();
         if(consoleLogValues) { console.log("Timestamp2: " + this.timestamp2) };


     } else {


         console.log("Timestamp num not valid. Must be 1 or 2.");


     }


     // Methode, die Zeitstempel-Differenz in Array aufnimmt
     addTimestampToTotal(counter: number, consoleLogValues: boolean){


         this.timestampTotal[counter] = this.timestamp1-this.timestamp2;
         if(consoleLogValues) { console.log(">>> TIMESTAMP-Total: " + this.timestampTotal[counter]) };


     }
 }

In diesem Code-Abschnitt wird ersichtlich, dass ich nur eine Methode für das erfassen von zwei Zeitstempeln verwende. Dies ist deutlich sinnvoller (weniger Code, übersichtlicher, schnellere Wartung, weniger Fehler) und entspricht dem Modell der objektorientierten Programmierung. Damit die App jedoch weiss welcher Zeitstempel gemeint ist, wird ein Parameter (num: number) beim Aufrufen der Funktion in metronome.component.ts an die Methode weitergegeben.

Man kann ausserdem erkennen, wie das aktuelle, tatsächliche (= gemessene) Intervall berechnet wird (Zeile 38). Hierfür wird bei jedem Tick des Metronoms die Differenz beider Timestamps in einem Array (Variable, die mehrere Werte speichern kann) gespeichert.

Auf dem Bild (Desktop: rechts, Mobile: unterhalb) sieht man, was die Konsole schlussendlich ausgibt, wenn das Metronom gestartet wird. Schön ersichtlich ist, wie jede halbe Sekunde ein neuer Auszug generiert wird. Der Timestamp-Total gibt Aufschluss über das tatsächlich ausgeführte Intervall.




 // Methode, die Ergebnisse des timestampTotal-Arrays analysiert
 analyze(theoreticalInterval: number){



     let dateSum = 0;



     // for-Schleife, welche die Werte des timestampTotal-Arrays addiert (in dateSum)
     for( let i = 1; i < this.timestampTotal.length; i++ ){
         dateSum += this.timestampTotal[i];
     }



     // Berechnen des Intervall-Durchschnitts
     this.intervalAverage = dateSum/(this.timestampTotal.length-1); // -1 weil der erste wert des Arrays[0] = 0 ist (Arrays fangen mit 0, nicht 1 an);



     // Filtere grössten Wert des timestampTotal-Arrays heraus
     let largest = Math.max.apply(Math, this.timestampTotal);



     this.timestampTotal[0] = 9999999; // Der erste Wert des Arrays ist 0, neu gesetzt wird eine unrealistisch hohe Zahl
     let smallest = Math.min.apply(Math, this.timestampTotal); // Filtere kleinsten Wert des Arrays heraus



     // Ausgabe der Ergebnisse in der Konsole
     console.log("XXXXXXXXXXXXX");
     console.log("-------------");
     console.log("PERCORMANCE ANALYSIS (all values in ms) \n\n");
     console.log("THEORETICAL INTERVAL: " + theoreticalInterval);
     console.log("MEASURED INTERVAL AVERAGE: " + this.intervalAverage);
     console.log("-------------");
     console.log("Largest Interval (meas.): " + largest);
     console.log("Smallest Interval (meas.): " + smallest);
     console.log("-------------");
     console.log("Variance: " + (this.intervalAverage - theoreticalInterval));
     console.log("Measured Interval Range: " + (largest-smallest));
 }



Eine kleine Anmerkung zu diesem Code-Auszug: Auf Zeile 64 wird der erste Wert des Arrays[0] = 0 auf Array[0] = 9’999’999, um den kleinsten Wert des Arrays (neben der 0) zu finden. Ansonsten würde der Filter immer 0 ausgeben, da der erste Wert des Arrays 0 ist (oben im Blogpost wurde beschrieben wieso: metronome.component.ts, Zeile 43 und performance-test.service.ts, Zeile 4).

Und was sagt die Auswertung?

Die Auswertung

Das Ergebnis ist (relativ) zufriedenstellend. Das Metronom hatte in diesem Durchgang eine durchschnittliche Abweichung von 4.078 Millisekunden – ein zeitlicher Bereich, den wir kaum feststellen können. Ebenso interessanter ist für mich der grösste und kleinste Wert. Mit 501 Millisekunden (als kleinstes der 1500 Intervalle) kann ich schlussfolgern: Ich war mit jedem Tick mindestens eine Millisekunde zu spät. Beunruhigender ist jedoch der grösste Wert mit 951. Das ist… sehr schlecht: Eine Abweichung um knapp 100%. Zwar war das Android-Gerät emuliert (und hatte daher weniger RAM, Prozessorkapazität als ein physisches Handy), dennoch darf eine solch grosse Abweichung nicht geschehen. Das Metronom ist zwar nicht vollständig nutzlos, aber wenn das Handy plötzlich heftig Berechnungen im Hintergrund machen möchte (z.B. wenn der Nutzer nebenbei noch andere, grosse Apps startet o.Ä.), dann gerät die Verzögerung plötzlich aus dem Lot (fängt sich aber sofort wieder => … 505, 951, 508 …). Um dem entgegenzuwirken plane ich bereits eine neue Version des Metronoms, welche die Timeout-Ausführung und das Abspielen des Sounds in einen Hintergrundprozess verlegt. Derzeit unterliegt der ganze Code dem UI (User-Interface) und ist daher hinfällig für Lags (Verzögerungen), die meine präzise und gewollte Verzögerung zerstören. Mit der eben veröffentlichten NativeScript Version 2.4 ist es mir jedoch möglich Hintergrundberechnungen auszuführen. Ich hoffe, dass mein Plan aufgeht und die Performance des Metronoms durch diese Anpassung deutlich verbessert wird.

Mit diesem Artikel wurde wohl ersichtlich wie viele komplexe Strukturen hinter einer „kleinen Funktion“ stecken können. Wie bereits angedeutet werde ich demnächst eine Überarbeitung des Metronoms vornehmen (Performance & mehr).

Wer sich den genauen Code ansehen möchte kann dies wie immer auf Github tun. Dies ist der Link zum Commit (dieses Artikels):

apphovenAlpha (Commit 83a57e5)