A Go program displays song lyrics line by line
Programming Snapshot – Go Lyrics
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
zztop.yaml
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.
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.
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
lyrics.go
01 package main 02 03 import ( 04 ui "github.com/gizak/termui/v3" 05 "github.com/gizak/termui/v3/widgets" 06 "sort" 07 ) 08 09 func main() { 10 songdir := "data" 11 lyrics, err := songFinder(songdir) 12 if err != nil { 13 panic(err) 14 } 15 16 if err := ui.Init(); err != nil { 17 panic(err) 18 } 19 defer ui.Close() 20 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) 34 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) 43 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
find.go
01 package main 02 03 import ( 04 "fmt" 05 "gopkg.in/yaml.v2" 06 "io/ioutil" 07 "os" 08 "path/filepath" 09 "regexp" 10 ) 11 12 type Lyrics struct { 13 Song string `yaml:"song"` 14 Artist string `yaml:"artist"` 15 Text string `yaml:text` 16 } 17 18 func songFinder(dir string) (map[string]Lyrics, error) { 19 lyrics := map[string]Lyrics{} 20 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 } 38 39 func parseSongFile(path string) (Lyrics, error) { 40 l := Lyrics{} 41 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
uihandler.go
01 package main 02 03 import ( 04 "bufio" 05 ui "github.com/gizak/termui/v3" 06 "github.com/gizak/termui/v3/widgets" 07 "strings" 08 ) 09 10 func handleUI(lb *widgets.List, ltext *widgets.List, 11 lyrics map[string]Lyrics) { 12 13 ui.Render(lb) 14 inFocus := lb 15 16 uiEvents := ui.PollEvents() 17 var scanner *bufio.Scanner 18 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
(incl. VAT)
Buy Linux Magazine
Subscribe to our Linux Newsletters
Find Linux and Open Source Jobs
Subscribe to our ADMIN Newsletters
Support Our Work
Linux Magazine content is made possible with support from readers like you. Please consider contributing when you’ve found an article to be beneficial.
News
-
Systemd Fixes Bug While Facing New Challenger in GNU Shepherd
The systemd developers have fixed a really nasty bug amid the release of the new GNU Shepherd init system.
-
AlmaLinux 10.0 Beta Released
The AlmaLinux OS Foundation has announced the availability of AlmaLinux 10.0 Beta ("Purple Lion") for all supported devices with significant changes.
-
Gnome 47.2 Now Available
Gnome 47.2 is now available for general use but don't expect much in the way of newness, as this is all about improvements and bug fixes.
-
Latest Cinnamon Desktop Releases with a Bold New Look
Just in time for the holidays, the developer of the Cinnamon desktop has shipped a new release to help spice up your eggnog with new features and a new look.
-
Armbian 24.11 Released with Expanded Hardware Support
If you've been waiting for Armbian to support OrangePi 5 Max and Radxa ROCK 5B+, the wait is over.
-
SUSE Renames Several Products for Better Name Recognition
SUSE has been a very powerful player in the European market, but it knows it must branch out to gain serious traction. Will a name change do the trick?
-
ESET Discovers New Linux Malware
WolfsBane is an all-in-one malware that has hit the Linux operating system and includes a dropper, a launcher, and a backdoor.
-
New Linux Kernel Patch Allows Forcing a CPU Mitigation
Even when CPU mitigations can consume precious CPU cycles, it might not be a bad idea to allow users to enable them, even if your machine isn't vulnerable.
-
Red Hat Enterprise Linux 9.5 Released
Notify your friends, loved ones, and colleagues that the latest version of RHEL is available with plenty of enhancements.
-
Linux Sees Massive Performance Increase from a Single Line of Code
With one line of code, Intel was able to increase the performance of the Linux kernel by 4,000 percent.