uku - A Haskell CLI tool to display Ukulele fingering charts

— 2583 Words — 13 min

TLDR: This is a tutorial on how to write a CLI tool in Haskell to display fingering charts for the Ukulele in your terminal. As this post is written in literate Haskell, it also contains the complete code for the program itself. If you just want to use the tool check out the short how to at the end.

While I originally started to write this 2 years ago (I thought it's about time to finish it 😛) in JavaScript, I recently got introduced to Haskell and it's awesome. Especially for building CLI tools.

First a short overview of what it's actually supposed to do. This is our target output:

Output of command "uku g"

You specify a chord and uku pretty prints the fingering chart as an ANSI art chord box to the terminal. Cool, right? So let's get started with the code:

First, we need to set a few compiler settings and import some basic modules:

{-# OPTIONS_GHC -Wall -Wincomplete-uni-patterns #-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE MultiWayIf #-}

module Main where

import Protolude as Pl

import Data.List.Index (setAt, imap)
import Data.Map.Strict as Map
import Data.Text as Text
import Unsafe (unsafeHead)

Now we need types to model our domain. Normally you'd expect something like data Note = C | Cis | D | Dis …, but this notation is mostly used for historical reasons and not for its ingeniousness. E.g. the distance between E and F is half the distance of F to G 🤦. The notation just doesn't make a lot of sense in times of the twelve-tone equal temperament. I'll call this notation the "arachaic notation" for the rest of the post.

Actually, even the notion of absolute note values isn't particularly useful, as western music is inherently relative and you can start the same song from every note. To accommodate this we simply model everything relatively and only make the common note names and the particular tuning of the Ukulele a special instance of it. I'll call this notation the "relative notation".

A Piano supports 88 notes and MIDI supports 128 notes. An octave contains 12 notes and so we can use a base 12 (duodecimal) system to simplify counting in octaves. The duodecimal system uses 2 special unicode characters for ten and eleven, called pitman digits: 0 1 2 3 4 5 6 7 8 9 ↊ ↋. Unfortunately GHC interprets them as symbols and does not allow them in regular names (Explanation on stackoverflow). For that reason we replace with X (like the Roman literal for 10) and with E (like "eleven"), which is the recommend way for ASCII text by the Dozenal Society of America. Each step corresponds to one semi tone in archaic notation.

Our Interval data type:

-- data Interval
--   = I00 | I01 | I02 | … | I09 | I0X | I0E
--   | I10 | …                         | IEE

The first duodecimal number after the I is the octave and the second one is the semi tone. To spare you the complete list, I appended it to the end of the post.

Explicitly listing all possible intervals has the advantage that we can now ensure at compile time that no invalid intervals are specified.

This amounts to 144 intervals, which is great, as it's slightly more than the 128 notes defined in MIDI and therefore we can model everything that MIDI can.

For specifying absolute note values, which we'll need at some point, we simply use the MIDI values in dozenal notation:

-- data MidiNote
--   = M00 | M01 | M02 | … | M09 | M0X | M0E
--   | M10 |                         … | MX7

This can easily be translated to the archaic notation, as M00 is C-1, M10 is C0, M20 is C1 and so on. I appended the full list to the end of the post.

We can now map this to our Ukulele:

Archaic notation    Relative to C4       Absolute MIDI notes

A4 ╓──┬──┬──┬──┬    I09 ╓──┬──┬──┬──┬    M59 ╓──┬──┬──┬──┬
E4 ╟──┼──┼──┼──┼    I04 ╟──┼──┼──┼──┼    M54 ╟──┼──┼──┼──┼
C4 ╟──┼──┼──┼──┼    I00 ╟──┼──┼──┼──┼    M50 ╟──┼──┼──┼──┼
G4 ╙──┴──┴──┴──┴    I07 ╙──┴──┴──┴──┴    M57 ╙──┴──┴──┴──┴

To completely model the domain we need some more types ... like for our 5 fingers 🤚.

data Finger = Thumb | Index | Middle | Ring | Pinky | AnyFinger
  deriving (Eq, Ord, Show)

Each finger will later be rendered by printing its first character. We also add ANSI color codes to colorize the terminal output.

fingerToText :: Finger -> Text
fingerToText finger =
  let colorize text = "\x1b[31m" <> text <> "\x1b[0m"
  in colorize $ case finger of
    Thumb     -> "T"
    Index     -> "I"
    Middle    -> "M"
    Ring      -> "R"
    Pinky     -> "P"
    AnyFinger -> "●"

Now we need to make it possible to pick a string at a certain position, play the string open, or mute the string (Attention: "Strings" always refers to the Ukulele strings. The datatype to store a string of characters is called Text).

To model the pick position we'll have to define a fret position in the range 1 to length of fretboard. There is, however, no good type safe way to model this with integers. 0 could mean open, but using a special value for it makes more sense. Normally fretted instruments don't have more than around 30 frets, so we'll just use the base 36 system without the zero (i.e. 1, …, 9, A, …, Z).

data FretPosition
  =      F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 | F9 | FA | FB
  | FC | FD | FE | FF | FG | FH | FI | FJ | FK | FL | FM | FN
  | FO | FP | FQ | FR | FS | FT | FU | FV | FW | FX | FY | FZ
  deriving (Bounded, Enum, Eq, Ord, Show)

Mute indicates to not play the string, Open means without picking it, and Pick defines the fretboard position and the finger to perform the pick with.

data Pick
  = Mute
  | Open
  | Pick FretPosition Finger
  deriving (Eq, Ord, Show)

To be able to perform calculations with fretboard positions we define a function to convert a Pick to an Int.

pickToInt :: Pick -> Int
pickToInt fretPosition =
  case fretPosition of
      Pick fret _ -> (fromEnum fret) + 1
      _ -> 0

One complete fretting of a chord is defined as:

type Fretting = [[Pick]]

Explanation: Several fingers can pick one string and that for each string. The strings are listed from I07/G4 (placed at the top of the fretboard) to I09/A4 (placed at the bottom). That's what your fretboard looks like when you look at the Ukulele as depicted in the chord boxes from above.

Finally we define a played fretted instrument by a list of relative notes for all strings, it's base note (the note of the lowest string) and the current fingering / fretting pattern.

type InstStrings = [Interval]
data PlayedInstrument = PlayedInst InstStrings MidiNote Fretting
type Instrument = Fretting -> PlayedInstrument

The cool thing about this representation is that if you want to change the tuning of the instrument (e.g. by using a capo) or want to use a differntly tuned Ukulele you only have to change the base note, instead of changing each string.

Based on this PlayedInstrument data type we can define a normal Ukulele by partially applying the data constructor like this:

ukulele :: Instrument
ukulele = PlayedInst [I07, I00, I04, I09] M34

I added the type signature to make it clearer. Our ukulele is now an Instrument, aka a function which gets applied to a finger pattern and returns a played instrument. Makes sense, right?

And now we can finally play our first chords on the ukulele 🎉. For example G major:

gMajor :: PlayedInstrument
gMajor = ukulele [
    [Open], [Pick F2 Index], [Pick F3 Ring], [Pick F2 Middle]
  ]

--     G
--  ╒═╤═╤═╕
--  │_│_│_│
--  │_I_│_M
--  │_│_R_│
--  │_│_│_│

Or B major (Formatted as 4 columns to show the relation between the datatype format and the output.):

bMajor :: PlayedInstrument
bMajor = ukulele [
    [Pick F2 Index,
     Pick F4 Ring  ], [Pick F2 Index,
                       Pick F3 Middle], [Pick F2 Index], [Pick F2 Index]
  ]

--     G
--  ╒═╤═╤═╕
--  │_│_│_│
--  I_I_I_I
--  │_M_│_│
--  R_│_│_│
--  │_│_│_│

Although we've now invented a more logical musical notation system, we still need a way to map from the archaic notation to the fingering patterns. We could try to write a function to automatically generate all of them, but deciding which finger to use for which pick is based on the anatomy of hands and it would be really hard to model this. (Feel free to prove me wrong 😉.) So instead of overengineering it, we'll stick to a simple manually generated lookup table.

I'll give you a short excerpt of the map, so you know what it looks like and move the complete map to the end of the post. Note that for each chord there are several ways how the chord can be picked. (Sorted from most to least common.)

archaicToFrettingA :: Map Text [Fretting]
archaicToFrettingA = Map.fromList [
    ("a", [
      [[Pick F2 Middle], [Pick F1 Index], [Open], [Open]],
      [[Pick F2 Middle], [Pick F1 Index], [Open], [Pick F4 Pinky]]
    ]),
    ("am", [
      [[Pick F2 Middle], [Open], [Open], [Open]],
      [[Pick F2 Middle], [Open], [Open], [Pick F3 AnyFinger]]
    ])
  ]

The next step is to write a set of functions to render the fretting model to our ANSI art chart boxes. This only works if the chord is defined.

Generate the played instrument:

chordToPlayedInsts ::
  Text -> Instrument -> Either Text [PlayedInstrument]
chordToPlayedInsts chord instrument =
  let
    maybeInst = do
      frettings <- Map.lookup chord archaicToFretting
      pure $ fmap instrument frettings
    errorMessage =
      "There is no fretting available for the specified chord"
  in
    maybeToEither errorMessage maybeInst

Render the pick onto an open string text:

putPickOnString :: Pick -> [Text] -> [Text]
putPickOnString pick stringParts =
  case pick of
    Mute -> stringParts
    Open -> stringParts
    (Pick _ finger) ->
      setAt
        (pickToInt pick)
        (fingerToText finger)
        stringParts

Get the rendering of each string:

getString :: Int -> Int -> Int -> [Pick] -> [Text]
getString numberOfFrets numOfStrings stringIndex strPick =
  let
    openString = [(if
      | stringIndex == 0                  -> "╒"
      | stringIndex == (numOfStrings - 1) -> "╕"
      | otherwise                         -> "╤")]
      <> Pl.replicate (numberOfFrets + 1) "│"
  in
    Pl.foldr putPickOnString openString strPick

Render the complete fretting:

showFretting :: Fretting -> Text
showFretting fretting =
  let
    maxPos = Pl.maximum $ fmap pickToInt $ fold fretting
  in
    fretting
      & imap (getString maxPos $ Pl.length fretting)
      & Pl.intersperse (["═"] <> Pl.replicate (maxPos + 1) "_")
      & Pl.transpose
      & Pl.intercalate ["\n"]
      & fold
      & (<> "\n")

Render the played instrument (Only works when number of strings per pick match with the number of strings of the instrument):

showPlayedInst :: PlayedInstrument -> Either Text Text
showPlayedInst (PlayedInst strings _ fretting)
  | Pl.length strings /= Pl.length fretting =
      Left "Number of strings and picks in fretting do not match"
  | otherwise = Right $
      showFretting fretting

Render it for each possible fretting for a certain chord:

getAnsiArts :: Text -> Either Text Text
getAnsiArts chord = do
  playedInsts <- chordToPlayedInsts chord ukulele
  fmap
    (Text.intercalate "\n")
    (mapM showPlayedInst playedInsts)

Finally a short main function to define the command line interface and handle I/O.

main :: IO ()
main = do
  chords <- getArgs
  if
    | Pl.length chords < 1 -> die "Usage: uku <chord>"
    | Pl.length chords > 1 -> die "Supportrs only 1 chord per call"
    | otherwise            ->
        case (getAnsiArts $ pack $ unsafeHead chords) of
          Left error -> die error
          Right ansiArt -> putStr $ ansiArt <> "\n"

And there we go: You now have a simple CLI tool to render Ukulele fingering charts. If you're looking for a challenge you could now extend it to Guitars!

Hope you liked it and if so, feel free to subscribe to my newsletter to get an email when I publish a new post! 😁


How to execute literate Haskell:

Turns out it's a little more involved when you want to write it in Markdown instead of LaTeX (Also because of issues with Kramdown and Pandoc). But following command will execute this post in most shells:

curl --silent \
  http://code.adriansieber.com/adrian/adriansieber-com/raw/branch/master/_posts/2018-09-12-ukulele-fingering-chart-cli-tool-in-haskell.md \
| sed 's/```haskell/```{.literate .haskell}/g' \
| pandoc \
  --from markdown \
  --to markdown+lhs \
  --output temp.lhs \
| stack runhaskell \
  --resolver lts-12.9 \
  --package protolude \
  --package ilist \
  -- temp.lhs a

Or to compile it to a uku executable replace the last part with:

| stack ghc \
  --resolver lts-12.9 \
  --package protolude \
  --package ilist \
  -- temp.lhs -o uku

All MIDI notes:

data MidiNote
  = M00 | M01 | M02 | M03 | M04 | M05 | M06 | M07 | M08 | M09 | M0X | M0E
  | M10 | M11 | M12 | M13 | M14 | M15 | M16 | M17 | M18 | M19 | M1X | M1E
  | M20 | M21 | M22 | M23 | M24 | M25 | M26 | M27 | M28 | M29 | M2X | M2E
  | M30 | M31 | M32 | M33 | M34 | M35 | M36 | M37 | M38 | M39 | M3X | M3E
  | M40 | M41 | M42 | M43 | M44 | M45 | M46 | M47 | M48 | M49 | M4X | M4E
  | M50 | M51 | M52 | M53 | M54 | M55 | M56 | M57 | M58 | M59 | M5X | M5E
  | M60 | M61 | M62 | M63 | M64 | M65 | M66 | M67 | M68 | M69 | M6X | M6E
  | M70 | M71 | M72 | M73 | M74 | M75 | M76 | M77 | M78 | M79 | M7X | M7E
  | M80 | M81 | M82 | M83 | M84 | M85 | M86 | M87 | M88 | M89 | M8X | M8E
  | M90 | M91 | M92 | M93 | M94 | M95 | M96 | M97 | M98 | M99 | M9X | M9E
  | MX0 | MX1 | MX2 | MX3 | MX4 | MX5 | MX6 | MX7
  deriving (Eq, Ord, Show)

All intervals:

data Interval
  = I00 | I01 | I02 | I03 | I04 | I05 | I06 | I07 | I08 | I09 | I0X | I0E
  | I10 | I11 | I12 | I13 | I14 | I15 | I16 | I17 | I18 | I19 | I1X | I1E
  | I20 | I21 | I22 | I23 | I24 | I25 | I26 | I27 | I28 | I29 | I2X | I2E
  | I30 | I31 | I32 | I33 | I34 | I35 | I36 | I37 | I38 | I39 | I3X | I3E
  | I40 | I41 | I42 | I43 | I44 | I45 | I46 | I47 | I48 | I49 | I4X | I4E
  | I50 | I51 | I52 | I53 | I54 | I55 | I56 | I57 | I58 | I59 | I5X | I5E
  | I60 | I61 | I62 | I63 | I64 | I65 | I66 | I67 | I68 | I69 | I6X | I6E
  | I70 | I71 | I72 | I73 | I74 | I75 | I76 | I77 | I78 | I79 | I7X | I7E
  | I80 | I81 | I82 | I83 | I84 | I85 | I86 | I87 | I88 | I89 | I8X | I8E
  | I90 | I91 | I92 | I93 | I94 | I95 | I96 | I97 | I98 | I99 | I9X | I9E
  | IX0 | IX1 | IX2 | IX3 | IX4 | IX5 | IX6 | IX7
  deriving (Eq, Ord, Show)

All frettings:

archaicToFretting :: Map Text [Fretting]
archaicToFretting = Map.fromList [
    ("a", [
      [[Pick F2 Middle], [Pick F1 Index], [Open], [Open]],
      [[Pick F4 Index, Pick F6 Ring],
        [Pick F4 Index], [Pick F4 Index, Pick F5 Middle], [Pick F4 Index]]
    ]),
    ("am", [
      [[Pick F2 Middle], [Open], [Open], [Open]]
    ]),

    ("a#", [
      [[Pick F1 Index, Pick F3 Ring],
        [Pick F1 Index, Pick F2 Middle], [Pick F1 Index], [Pick F1 Index]]
    ]),
    ("a#m", [
      [[Pick F1 Index, Pick F3 Ring],
        [Pick F1 Index], [Pick F1 Index], [Pick F1 Index]]
    ]),

    ("b", [
      [[Pick F2 Index, Pick F4 Ring],
        [Pick F2 Index, Pick F3 Middle], [Pick F2 Index], [Pick F2 Index]]
    ]),
    ("bm", [
      [[Pick F2 Index, Pick F4 Ring],
        [Pick F2 Index], [Pick F2 Index], [Pick F2 Index]]
    ]),

    ("c", [[[Open], [Open], [Open], [Pick F3 Ring]]]),
    ("cm", [[[Open], [Pick F3 Index], [Pick F3 Index], [Pick F3 Index]]]),

    ("c#", [
      [[Pick F1 Index],
        [Pick F1 Index], [Pick F1 Index], [Pick F1 Index, Pick F4 Pinky]
      ]
    ]),
    ("c#m", [[[Pick F1 Index], [Pick F1 Index], [Open], [Open]]]),

    ("d",  [[[Pick F2 Index], [Pick F2 Middle], [Pick F2 Middle], [Open]]]),
    ("dm",  [[[Pick F2 Middle], [Pick F2 Ring], [Pick F1 Index], [Open]]]),

    ("d#", [[[Open], [Pick F3 Ring], [Pick F3 Pinky], [Pick F1 Index]]]),
    ("d#m", [
      [[Pick F3 Ring], [Pick F3 Pinky], [Pick F2 Middle], [Pick F1 Index]]
    ]),

    ("e",  [[[Pick F1 Index], [Pick F4 Pinky], [Open], [Pick F2 Middle]]]),
    ("em",  [[[Open], [Pick F4 Ring], [Pick F3 Middle], [Pick F2 Index]]]),

    ("f",  [[[Pick F2 Middle], [Open], [Pick F1 Index], [Open]]]),
    ("fm",  [[[Pick F1 Index], [Open], [Pick F1 Middle], [Pick F3 Pinky]]]),

    ("f#", [
      [[Pick F1 Index, Pick F3 Ring],
        [Pick F1 Index], [Pick F1 Index, Pick F2 Middle], [Pick F1 Index]
      ]
    ]),
    ("f#m", [[[Pick F2 Middle], [Pick F1 Index], [Pick F2 Ring], [Open]]]),

    ("g",  [[[Open], [Pick F2 Index], [Pick F3 Ring], [Pick F2 Middle]]]),
    ("gm",  [[[Open], [Pick F2 Middle], [Pick F3 Ring], [Pick F1 Index]]]),

    ("g#", [
      [[Pick F3 Index, Pick F5 Ring],
        [Pick F3 Index], [Pick F3 Index, Pick F4 Middle], [Pick F3 Index]
      ]
    ]),
    ("g#m", [
      [[Pick F4 Ring], [Pick F3 Middle], [Pick F4 Pinky], [Pick F2 Index]]
    ])
  ]

If you have any comments, thoughts, or other feedback feel free to tweet me @AdrianSieber. Thanks for your help! 😊