Aus Linux-Magazin 10/2020

Go-Programm zeigt zeilenweise Lyrics zu Musiktiteln an

© nikkytok, 123RF

Badewannensänger Mike Schilli bastelt sich ein Go-Tool, das Songtexte aus YAML-Dateien verwaltet und ihm dabei hilft, sie Zeile für Zeile auswendig zu lernen.

Drei Akkorde kann jeder klimpern. Was ich an Musikern wirklich bewundere, ist, dass sie ihre oft umfangreichen Songtexte alle auswendig dahersingen können. Neulich fiel mir auf, dass ich den Text von “Ich möcht’ so gern Dave Dudley hör’n” der deutschen Band “Truck Stop” noch nie ganz verstanden hatte – und das Lied ist immerhin 32 Jahre alt.

Nach “‘n richtig schönen Country-Song” kommt eine Zeile, die ich im Kopf immer mit “nanana-eiiohwei” ergänzt hatte. Erst kürzlich fand ich durch Nachlesen des Songtexts im Internet heraus, dass die Band keineswegs “eiiohwei” singt, sondern “doch AFN ist weit”. AFN, der Ami-Sender in Deutschland, den ich als Kind hörte! Unglaublich.

Als portables Hilfsmittel, um Songtexte auswendig zu lernen, zeigt das Kommandozeilen-Tool in Go in dieser Ausgabe eine Liste von als ».yaml«-Dateien hinterlegten Songtexten zur Auswahl an. Wählt der Benutzer durch einen Druck auf die Eingabetaste ein Lied aus, kann er sich ebenfalls mit [Eingabe] Zeile für Zeile durch den Text klicken, und im Kopf jedes Mal versuchen, den kommenden Satz vorherzusagen.

Retro-Look

Das Tool läuft auf der Kommandozeile, sodass gestresste Sysadmins auch schnell eine Runde spielen können, während in einem anderen Fenster ein langwieriges Kommando läuft. Zwar erinnert die Ästhetik an die 80er-Jahre mit MS-DOS, aber genau wie 80er-Autos wie der Scirocco kommt alles wieder.

Wie schon in vorigen Ausgaben kommt das Paket termui zum Einsatz, das auf Curses aufsetzt und identisch auf Linux und dem Mac läuft. Nach dem Programmstart liest das Tool alle im Verzeichnis »data/« liegenden YAML-Dateien ein. Die definieren, wie in Listing 1 gezeigt, Felder für »artist«, »song« und »text«. Bei Letzterem handelt es sich um ein per Pipe-Zeichen eingeleitetes Multi-Line-Feld, das so lange fortfährt, bis die Texteinrückung entfällt oder die Datei endet.

Listing 1

zztop.yaml

artist: ZZ-Top
song: Sharp Dressed Man
text: |
  Clean shirt, new shoes
  And I don't know where I am goin' to
  Silk suit, black tie,
  I don't need a reason why
  They come runnin' just as fast as they can
  'Cause every girl crazy 'bout a sharp dressed man
  ...

Diese Dateien erstellt der Benutzer mithilfe von Texten aus den Lyrics-Datenbanken, die bei der Google-Suche eines Titels hochkommen. Die Terminal-UI des kompilierten Programms »lyrics« zeigt zunächst eine Liste von Titeln und deren Interpreten an (Abbildung 1). Mit den Pfeiltasten (Vim-Enthusiasten dürfen auch [K]+ und [J] verwenden) fährt der Anwender dann die Liste ab und drückt die Eingabetaste, um den ausgewählten Titel zu öffnen.

Abbildung 1: Die Listbox des Hauptmenüs stellt Songs zur Auswahl bereit.

Abbildung 1: Die Listbox des Hauptmenüs stellt Songs zur Auswahl bereit.

Zeile für Zeile

Nach der Auswahl wechselt die UI in den Liedtextmodus, zeigt die erste Songzeile an und schreitet bei jedem weiteren Druck auf die Eingabetaste um eine Zeile weiter (Abbildung 2). Wird der Anwender des Lieds überdrüssig, wechselt er mit [Esc]+ wieder ins Hauptmenü. Dasselbe erfolgt nach einem Druck auf [Eingabe] nach der letzten Liedzeile.

Abbildung 2: Das ebenfalls als Listbox implementierte Widget »ltext« gibt schrittweise den Songtext preis.

Abbildung 2: Das ebenfalls als Listbox implementierte Widget »ltext« gibt schrittweise den Songtext preis.

Die Ansichten der zwei verschiedenen Modi definiert Listing 2 als zwei Listboxen der Termui-Widget-Sammlung, die sich mit »SetRect()« genau dasselbe Rechteck innerhalb des Terminalfensters reservieren. Die UI erkennt später den aktuellen Modus und bringt die richtige Listbox auf den Schirm. Die Abmessungen des aktiven Terminals ermittelt die Funktion »TerminalDimensions()« in Zeile 29. Die daraus gewonnenen Werte für Breite (»w«) und Höhe (»h«) nutzt die UI, um sich auf ganzer Linie auszubreiten.

Listing 2

lyrics.go

package main
import (
  ui "github.com/gizak/termui/v3"
  "github.com/gizak/termui/v3/widgets"
  "sort"
)
func main() {
  songdir := "data"
  lyrics, err := songFinder(songdir)
  if err != nil {
    panic(err)
  }
  if err := ui.Init(); err != nil {
    panic(err)
  }
  defer ui.Close()
  // Listbox displaying songs
  lb := widgets.NewList()
  items := []string{}
  for k := range lyrics {
    items = append(items, k)
  }
  sort.Strings(items)
  lb.Title = "Pick a song"
  w, h := ui.TerminalDimensions()
  lb.SetRect(0, 0, w, h)
  lb.Rows = items
  lb.SelectedRow = 0
  lb.SelectedRowStyle = ui.NewStyle(ui.ColorGreen)
  // Listbox displaying lyrics
  ltext := widgets.NewList()
  ltextLines := []string{}
  ltext.Rows = ltextLines
  ltext.SetRect(0, 0, w, h)
  ltext.Title = "Text"
  ltext.TextStyle.Fg = ui.ColorGreen
  ltext.SelectedRowStyle = ui.NewStyle(ui.ColorRed)
  handleUI(lb, ltext, lyrics)
}

Als erste Aktion ruft Listing 2 in Zeile 11 die Funktion »songFinder()« auf, die die YAML-Dateien einsammelt und als Datenstruktur in »lyrics« zurückgibt. Erst danach initialisiert sie die UI mit »ui.Init()« und sorgt mit dem nachfolgenden »defer«-Statement dafür, dass Go den ganzen Zirkus wieder einpackt, sobald das Hauptprogramm endet. Das ist wichtig, denn in ein im Grafikmodus verbleibendes Terminal könnte der User nach Programmschluss nichts mehr eintippen.

Strukturen organisieren

Bei der Datenstruktur »lyrics« handelt es sich um eine Go-Map, die die YAML-Daten einzelner Songs referenziert, und zwar über einen String-Schlüssel mit einer Kombination aus Interpret und Titel. Sowohl die Datenstruktur einzelner Songs als auch die Map aller Songs definiert später erst Listing 3. Da jedoch alle drei Listings dasselbe Paket definieren, dürfen sie untereinander auf ihre Konstrukte zugreifen.

Die Liste der Einträge im Hauptmenü baut in Listing 2 die For-Schleife ab Zeile 24 in einem Array-Slice von Strings zusammen, die es aus den Schlüsseln der Lyrics-Map generiert. Diese liegen unsortiert vor, also bringt die Funktion »sort.Strings()« aus der Standardbibliothek die String-Liste in Zeile 27 in eine alphabetische Reihenfolge. Es fällt auf, dass Go ein Array-Slice von Strings in-place sortiert, also tatsächlich das Eingabe-Array modifiziert und nicht etwa ein neues, sortiertes produziert.

Listing 2 kümmert sich nun nur noch darum, bunte Farben für aktive und passive Listbox-Einträge zu definieren und die weitere Verarbeitung von Eingaben und UI-Darstellung an die Funktion »handleUI()« aus Listing 4 abzugeben. Kehrt diese zurück, hat der User [Q] gedrückt und wünscht die Singstunde zu beenden. Das Hauptprogramm langt so am Ende des Codes an, baut gemäß der vorher definierten »defer«-Anweisung die UI ab und beendet sich.

Musik als YAML

Das Aufstöbern von YAML-Dateien mit Musiktexten übernimmt die Funktion »songFinder()« aus Listing 3. Die Konvertierung von YAML-Daten in Go-Strukturen unterstützt Go von Haus aus. Es verlangt vom Programmierer lediglich, Go-Strukturen wie zum Beispiel »Lyrics« ab Zeile 12 mit Hinweisen zum YAML-Format zu dekorieren, falls die Schlüssel im YAML-Text von den Attributnamen der Go-Struktur abweichen.

So definiert »Lyrics« ab Zeile 12 jeweils in umgekehrten Anführungszeichen, dass Schlüssel in YAML konventionsgemäß mit Kleinbuchstaben beginnen, während öffentlich zugängliche Felder in Go-Strukturen groß geschrieben werden. Die erste Zeile der »Lyrics«-Struktur definiert zum Beispiel mit »Song string `yaml:”song”`«, dass das Feld »Song« vom Typ String ist, aber in YAML statt »Song« nun »song« heißt.

Listing 3

find.go

package main
import (
  "fmt"
  "gopkg.in/yaml.v2"
  "io/ioutil"
  "os"
  "path/filepath"
  "regexp"
)
type Lyrics struct {
  Song   string `yaml:"song"`
  Artist string `yaml:"artist"`
  Text   string `yaml:text`
}
func songFinder(dir string) (map[string]Lyrics, error) {
  lyrics := map[string]Lyrics{}
  err := filepath.Walk(dir,
    func(path string, info os.FileInfo, err error) error {
      ext := filepath.Ext(path)
      rx := regexp.MustCompile(".ya?ml")
      if !rx.Match([]byte(ext)) {
        return nil
      }
      song, err := parseSongFile(path)
      if err != nil {
        panic("Invalid song file: " + path)
      }
      key := fmt.Sprintf("%s|%s", song.Artist, song.Song)
      lyrics[key] = song
      return nil
    })
  return lyrics, err
}
func parseSongFile(path string) (Lyrics, error) {
  l := Lyrics{}
  d, err := ioutil.ReadFile(path)
  if err != nil {
    return l, err
  }
  err = yaml.Unmarshal([]byte(d), &l)
  if err != nil {
    return l, err
  }
  return l, nil
}

Mit dieser Dekoration wandelt die Funktion »Unmarshal()« in Zeile 46 die YAML-Daten mühelos ins interne »Lyrics«-Format um, ohne dass der Programmier etwas dazutun müsste.

So bleibt »songFinder()« nur, mit »filepath.Walk()« alle ».yaml«-Dateien (oder ».yml« per Regex) unterhalb des vorgegebenen Verzeichnisses einzusammeln, für jede gefundene Datei »parseSongFile()« aufzurufen und die Daten in Zeile 33 unter dem Interpreten-Titel-Schlüssel in die Map »lyrics« zu füttern.

Die Funktion »songFinder()« liefert qua Go-Konvention das Ergebnis als Variable zurück sowie einen im Erfolgsfall auf »nil« gesetzten Fehlercode, den sich das Hauptprogramm genau ansieht.

Aktionen durch Events

Das Verwalten der aus zwei übereinanderliegenden, sich gegenseitig verdeckenden Listboxen bestehenden Terminal-UI rechtfertigt eine eigene Funktion »handleUI()« in Listing 4. Um festzustellen, welche Listbox sichtbar ist, unterhält die Funktion die Variable »inFocus« und setzt sie entweder auf die Listbox des Hauptmenüs (»lb«) oder die des Textfensters »ltext«.

Listing 4

uihandler.go

package main
import (
  "bufio"
  ui "github.com/gizak/termui/v3"
  "github.com/gizak/termui/v3/widgets"
  "strings"
)
func handleUI(lb *widgets.List, ltext *widgets.List,
  lyrics map[string]Lyrics) {
  ui.Render(lb)
  inFocus := lb
  uiEvents := ui.PollEvents()
  var scanner *bufio.Scanner
  for {
    select {
    case e := <-uiEvents:
      switch e.ID {
      case "q", "<C-c>":
        return
      case "j", "<Down>":
        if inFocus == lb {
          lb.ScrollDown()
          ui.Render(lb)
        }
      case "k", "<Up>":
        if inFocus == lb {
          lb.ScrollUp()
          ui.Render(lb)
        }
      case "<Enter>":
        if inFocus == lb {
          sel := lb.Rows[lb.SelectedRow]
          ltext.Title = sel
          inFocus = ltext
          text := lyrics[sel].Text
          scanner = bufio.NewScanner(
            strings.NewReader(text))
          ui.Render(ltext)
        }
        if inFocus == ltext {
          morelines := false
          for scanner.Scan() {
            line := scanner.Text()
            if line == "" {
              continue
            }
            ltext.Rows = append(ltext.Rows, line)
            morelines = true
            ltext.ScrollDown()
            ui.Render(ltext)
            break
          }
          if !morelines {
            inFocus = lb
            ltext.Rows = ltext.Rows[:0]
            ui.Render(lb)
          }
        }
      case "<Escape>":
        inFocus = lb
        ltext.Rows = ltext.Rows[:0]
        ui.Render(lb)
      }
    }
  }
}

Wie bei grafischen Oberflächen üblich, startet Zeile 19 eine Endlosschleife, deren einzige Aktion eine »select«-Anweisung ist. Sie nimmt aus dem Kanal »uiEvents« Ereignisse entgegen, die die Termui-Bibliothek dort verbreitet. Drückt der Benutzer [Q], kommt durch den Kanal ein Event geschossen, dessen »ID«-Feld auf »q« gesetzt ist. Der »case«-Handler in Zeile 23 löst daraufhin ein »return« aus, was »handleUI()« beendet und dementsprechend auch das aufrufende Hauptprogramm.

Aktionen mit den Pfeiltasten spielen nur eine Rolle, falls »inFocus« anzeigt, dass das Hauptmenü aktiv ist. Die Zeilen 27 und 32 rufen in diesen Fällen die Funktionen »ScrollDown()« respektive »ScrollUp()« des Listbox-Widgets auf, gefolgt vom Befehl »ui.Render(lb)«, der das Widget neu zeichnet. So bekommt der Anwender die Änderung auch optisch mit.

Liefert der Kanal »uiEvents« ein Ereignis für die Eingabetaste, hängt die weitere Verarbeitung davon ab, welcher Modus gerade aktiv ist. Befindet sich der Nutzer im Hauptmenü, steht »inFocus« auf »lb«, und Zeile 37 holt mit dem numerischen Index des ausgewählten Listbox-Eintrags dessen Textdarstellung, also Interpret und Titel, aus der Listbox.

Anschließend setzt der If-Block die Variable »inFocus« auf »ltext«, macht also das Songtext-Fenster aktiv. Ein in Zeile *41 neu definierter Scanner schnappt sich den Textstring des Liedtexts aus der Lyrics-Struktur und liefert bei folgenden Aufrufen seiner Methode »Scan()« jeweils die nächste String-Zeile zurück. Den Wechsel vom Hauptmenü in den Songtext-Modus leitet Zeile 43 schließlich auch für den User sichtbar ein, indem es »ui.Render()« die Text-Listbox mitgibt.

Hat der User hingegen im Songtext-Modus [Eingabe] gedrückt, kommt der If-Block ab Zeile 45 zum Einsatz. Dort holt der Text-Scanner ab Zeile 47 die nächste Liedzeile aus dem Multi-Line-String der YAML-Daten, verwirft etwaige Leerzeilen und hängt neu gelesene an das Array-Slice der Listbox »ltext« an. Ein »ScrollDown()« markiert die neue Zeile in der Anzeige als ausgewähltes Element und färbt deren Text rot ein. Sichtbar wird das Ganze wie immer erst nach einem »ui.Render()«.

Endet der Song, kommen also keine weiteren Zeilen mehr vom Scanner, dann tilgt der If-Block ab Zeile 58 die Textzeilen aus der Listbox »ltext« und wechselt wieder in den Hauptmenü-Modus, indem er »inFocus« auf »lb« setzt. Dasselbe passiert, falls der Anwender [Esc] drückt; in diesem Fall schaltet der Case-Block ab Zeile 64 in derselben Weise ins Hauptmenü.

Schnell nachgebaut

Zum Erzeugen der Binärdatei »lyrics«, die das Tool von A bis Z steuert, kompiliert man lediglich alle drei Codedateien, wie das Listing 5 zeigt. Der vorausgehende Aufruf von »go mod« initialisiert ein neues Go-Modul, um das nachfolgende »go build« zu veranlassen, alle erforderlichen Pakete auf Github abzuholen und ebenfalls einzubinden.

Listing 5

Binary erzeugen

$ go mod init lyrics
$ go build lyrics.go find.go uihandler.go

Nach dem erfolgreichem Build sucht ein Aufruf von »lyrics« nach einem Verzeichnis »data«, in dem YAML-Dateien mit den Einträgen »artist«, »title« und »text« liegen. Dann kann der Spaß beginnen. ((uba)/(jlu))

Der Autor

Michael Schilli arbeitet als Software Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mailto:mschilli@perlmeister.com beantwortet er gern Fragen.

Online PLUS

Im Screencast unter http://www.linux-magazin.de/videos/ demonstriert Michael Schilli das vorgestellte Programmierbeispiel.

DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 5 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben