A Go program displays song lyrics line by line

Programming Snapshot – Go Lyrics

© Lead Image © U.P. Images, Fotolia.com

© Lead Image © U.P. Images, Fotolia.com

Article from Issue 241/2020

Bathtub singer Mike Schilli builds a Go tool that manages song lyrics from YAML files and helps him learn them by heart, line by line.

Anyone can strum three chords. What I really admire about musicians is their ability to sing all of their often lengthy lyrics by heart. Having said this, there are some hilarious examples of the massive divide between what the artists originally sang and what the fans thought they heard.

Take the Eurythmics song "Sweet Dreams," for example; although some people's sweet dreams may be made of cheese, it's not what Annie Lennox and Dave Stewart had in mind. Or, keeping to a foodie theme, there's the hilarious mishearing of the 1980s Starship classic "We built this city on sausage rolls," – the city in question being San Francisco, my own place of residence, which of course is more famous for rock and roll.

As a portable tool to help budding singers learn lyrics by heart, the command-line tool in Go in this issue shows a list of lyrics stored as YAML files for selection. After pressing the Enter key to select a song, you can also press Enter to click through the lyrics line by line and try to remember what comes next before you continue.

Retro Look

The tool runs on the command line, so stressed sys admins can have a quick sing while a lengthy command is running in another window. You may notice that the aesthetics are reminiscent of the '80s with MS-DOS. Just like '80s cars like the Scirocco, a lot from that era is making a comeback in 2020.

As in some previous issues, I will be using the termui package, which is based on curses and runs identically on Linux and macOS. After the program launches, the tool reads all the YAML files in the data/ directory. As shown in Listing 1, the lyrics files define fields for artist, song, and text. The latter is a multi-line field initiated by a pipe character, and the field data continues until the text indentations stop, or the file ends.

Listing 1


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

You can create these files by copying and pasting lyrics from websites that come up when you search for a track on Google. The terminal UI of the compiled lyrics program first displays a list of tracks and their artists (Figure 1). Using the arrow keys (Vim enthusiasts can use K and J if they prefer), you can then scroll through the list and press Enter to open the selected title.

Figure 1: The list box in the main menu provides a selection of tracks.

Line by Line

After you make a selection, the UI enters lyrics mode, displaying the first line of the song, and moving down one line each time the Enter key is pressed (Figure  2). If you get tired of the song, pressing Esc takes you back to the main menu. The same thing happens when you press Enter after the last line of the song.

Figure 2: The ltext widget, which is also implemented as a list box, reveals the lyrics line by line.

Listing 2 defines the views for the two different modes as two list boxes from the termui widget collection; they both use SetRect() to claim exactly the same fully-sized rectangle within the terminal window. The UI later detects the current mode and brings the correct list box to the front. The size of the active terminal is determined by the TerminalDimensions() function in line 29. The UI uses the values for width (w) and height (h) obtained from the call to spread out over all available screen real estate.

Listing 2


01 package main
03 import (
04   ui "github.com/gizak/termui/v3"
05   "github.com/gizak/termui/v3/widgets"
06   "sort"
07 )
09 func main() {
10   songdir := "data"
11   lyrics, err := songFinder(songdir)
12   if err != nil {
13     panic(err)
14   }
16   if err := ui.Init(); err != nil {
17     panic(err)
18   }
19   defer ui.Close()
21   // Listbox displaying songs
22   lb := widgets.NewList()
23   items := []string{}
24   for k := range lyrics {
25     items = append(items, k)
26   }
27   sort.Strings(items)
28   lb.Title = "Pick a song"
29   w, h := ui.TerminalDimensions()
30   lb.SetRect(0, 0, w, h)
31   lb.Rows = items
32   lb.SelectedRow = 0
33   lb.SelectedRowStyle = ui.NewStyle(ui.ColorGreen)
35   // Listbox displaying lyrics
36   ltext := widgets.NewList()
37   ltextLines := []string{}
38   ltext.Rows = ltextLines
39   ltext.SetRect(0, 0, w, h)
40   ltext.Title = "Text"
41   ltext.TextStyle.Fg = ui.ColorGreen
42   ltext.SelectedRowStyle = ui.NewStyle(ui.ColorRed)
44   handleUI(lb, ltext, lyrics)
45 }

As its first action, Listing 2 calls the songFinder() function in line 11, which collects the YAML files and returns them as a data structure in lyrics. It then initializes the UI with ui.Init() and uses the following defer statement to make sure that Go closes down the whole enchilada again as soon as the main program terminates. This is important, because if the terminal stayed in graphic mode after the program abruptly terminated itself, the user would see garbled characters and be unable to use it for typing future shell commands.

Organizing Structures

The lyrics data structure is a Go map that references the YAML data of individual tracks for fast lookups, using a string key consisting of a combination of the artist and title. Both the data structures of the individual songs and the map of the song collection will be defined later in Listing 3, but since all three listings implement the same Go package main, they are allowed mutual access to each other's constructs.

Listing 3


01 package main
03 import (
04   "fmt"
05   "gopkg.in/yaml.v2"
06   "io/ioutil"
07   "os"
08   "path/filepath"
09   "regexp"
10 )
12 type Lyrics struct {
13   Song   string `yaml:"song"`
14   Artist string `yaml:"artist"`
15   Text   string `yaml:text`
16 }
18 func songFinder(dir string) (map[string]Lyrics, error) {
19   lyrics := map[string]Lyrics{}
21   err := filepath.Walk(dir,
22     func(path string, info os.FileInfo, err error) error {
23       ext := filepath.Ext(path)
24       rx := regexp.MustCompile(".ya?ml")
25       if !rx.Match([]byte(ext)) {
26         return nil
27       }
28       song, err := parseSongFile(path)
29       if err != nil {
30         panic("Invalid song file: " + path)
31       }
32       key := fmt.Sprintf("%s|%s", song.Artist, song.Song)
33       lyrics[key] = song
34       return nil
35     })
36   return lyrics, err
37 }
39 func parseSongFile(path string) (Lyrics, error) {
40   l := Lyrics{}
42   d, err := ioutil.ReadFile(path)
43   if err != nil {
44     return l, err
45   }
46   err = yaml.Unmarshal([]byte(d), &l)
47   if err != nil {
48     return l, err
49   }
50   return l, nil
51 }

In Listing 2, the for loop starting in line 24 assembles the list of entries in the main menu as an array slice of strings that it generates from the lyrics map's keys. The keys in the map are by definition unsorted; therefore, the sort.Strings() function from the standard library puts the string list in alphabetical order in line 27. Go's built-in Sort() function sorts an array slice of strings in place (i.e., it actually modifies the input array instead of producing a new, sorted version).

Listing 2 now only needs to take care of defining bright colors for active and passive list box entries and handing over the downstream processing of input and UI display to the handleUI() function shown in Listing 4. When the function returns, it's because the user has pressed Q and wants to end their singing lesson. The main program reaches the end of the code, dismantles the UI based on the previously defined defer statement, and terminates.

Listing 4


01 package main
03 import (
04   "bufio"
05   ui "github.com/gizak/termui/v3"
06   "github.com/gizak/termui/v3/widgets"
07   "strings"
08 )
10 func handleUI(lb *widgets.List, ltext *widgets.List,
11   lyrics map[string]Lyrics) {
13   ui.Render(lb)
14   inFocus := lb
16   uiEvents := ui.PollEvents()
17   var scanner *bufio.Scanner
19   for {
20     select {
21     case e := <-uiEvents:
22       switch e.ID {
23       case "q", "<C-c>":
24         return
25       case "j", "<Down>":
26         if inFocus == lb {
27           lb.ScrollDown()
28           ui.Render(lb)
29         }
30       case "k", "<Up>":
31         if inFocus == lb {
32           lb.ScrollUp()
33           ui.Render(lb)
34         }
35       case "<Enter>":
36         if inFocus == lb {
37           sel := lb.Rows[lb.SelectedRow]
38           ltext.Title = sel
39           inFocus = ltext
40           text := lyrics[sel].Text
41           scanner = bufio.NewScanner(
42             strings.NewReader(text))
43           ui.Render(ltext)
44         }
45         if inFocus == ltext {
46           morelines := false
47           for scanner.Scan() {
48             line := scanner.Text()
49             if line == "" {
50               continue
51             }
52             ltext.Rows = append(ltext.Rows, line)
53             morelines = true
54             ltext.ScrollDown()
55             ui.Render(ltext)
56             break
57           }
58           if !morelines {
59             inFocus = lb
60             ltext.Rows = ltext.Rows[:0]
61             ui.Render(lb)
62           }
63         }
64       case "<Escape>":
65         inFocus = lb
66         ltext.Rows = ltext.Rows[:0]
67         ui.Render(lb)
68       }
69     }
70   }
71 }

Buy this article as PDF

Express-Checkout as PDF
Price $2.95
(incl. VAT)

Buy Linux Magazine

Get it on Google Play

US / Canada

Get it on Google Play

UK / Australia

Related content

  • Waxing Lyrical

    Whether you listen to music on Spotify or a classic audio player like Rhythmbox, Lollypop, or Audacious, a tool named Lyrics-in-terminal will let you read the lyrics for the track you are currently playing.

  • ChordPro

    If you play a stringed instrument and want to record lyrics and chord changes, ChordPro gives you an elegant and convenient approach to getting it all on paper.

  • Introduction

    This month in Linux Voice.

  • Sayonara Player

    For a simple audio player, check out Sayonara Player, a great choice for enjoying all your favorite music, Internet radio, and podcasts.

  • Free Software Projects

    Linux is a wonderful and underrated audio production platform, with great applications for every audio task. MuseScore and LilyPond bring elegance and sophistication to score writing, and Chordii is a wonderfully simple guitar sheet-music maker.

comments powered by Disqus