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
-
Halcyon Creates Anti-Ransomware Protection for Linux
As more Linux systems are targeted by ransomware, Halcyon is stepping up its protection.
-
Valve and Arch Linux Announce Collaboration
Valve and Arch have come together for two projects that will have a serious impact on the Linux distribution.
-
Hacker Successfully Runs Linux on a CPU from the Early ‘70s
From the office of "Look what I can do," Dmitry Grinberg was able to get Linux running on a processor that was created in 1971.
-
OSI and LPI Form Strategic Alliance
With a goal of strengthening Linux and open source communities, this new alliance aims to nurture the growth of more highly skilled professionals.
-
Fedora 41 Beta Available with Some Interesting Additions
If you're a Fedora fan, you'll be excited to hear the beta version of the latest release is now available for testing and includes plenty of updates.
-
AlmaLinux Unveils New Hardware Certification Process
The AlmaLinux Hardware Certification Program run by the Certification Special Interest Group (SIG) aims to ensure seamless compatibility between AlmaLinux and a wide range of hardware configurations.
-
Wind River Introduces eLxr Pro Linux Solution
eLxr Pro offers an end-to-end Linux solution backed by expert commercial support.
-
Juno Tab 3 Launches with Ubuntu 24.04
Anyone looking for a full-blown Linux tablet need look no further. Juno has released the Tab 3.
-
New KDE Slimbook Plasma Available for Preorder
Powered by an AMD Ryzen CPU, the latest KDE Slimbook laptop is powerful enough for local AI tasks.
-
Rhino Linux Announces Latest "Quick Update"
If you prefer your Linux distribution to be of the rolling type, Rhino Linux delivers a beautiful and reliable experience.