When the MP3 standard was invented, it did not contain a way to store information about the song until in 1996 Eric Kemp came up with the idea of appending 128 bytes of data to the end of the file containing information like the artist and the album. This information is known as an ID3 tag. This first version, known as ID3v1, was amended in 1997 to optionally store the track number, resulting in ID3v1.1.
In 1998 ID3v2 was released, which is a far more complicated and comprehensive, though technically unrelated, standard.
Your task in today’s exercise is to write a program that can read ID3v1.1 tags. The layout of the ID3 tag can be found in the wikipedia article. When you are finished, you are welcome to read a suggested solution below, or to post your own solution or discuss the exercise in the comments below (to post code, put it between [code][/code] tags).
First, some imports:
import Control.Applicative.Error import qualified Data.ByteString as B import Data.ByteString.Char8 (unpack) import Data.List.Split
We could just use a tuple to store the ID3 info, but that would make function signatures rather hard to read, so we’ll define a data type for it.
data ID3v1 = ID3v1 String String String (Maybe Int) String (Maybe Int) String deriving Show
Since the genre information is stored in a single byte, we’ll need to convert it to the genre name. This is trivial, but the genres take up a lot of space.
genre :: Int -> String genre c = if c > 147 then "Unknown" else wordsBy (== ',') "Blues,Classic Rock,Country,Dance,Disco,Funk,Grunge,Hip-Hop,\ \Jazz,Metal,New Age,Oldies,Other,Pop,R&B,Rap,Reggae,Rock,\ \Techno,Industrial,Alternative,Ska,Death Metal,Pranks,\ \Soundtrack,Euro-Techno,Ambient,Trip-Hop,Vocal,Jazz+Funk,\ \Fusion,Trance,Classical,Instrumental,Acid,House,Game,\ \Sound Clip,Gospel,Noise,Alternative Rock,Bass,Soul,Punk,\ \Space,Meditative,Instrumental Pop,Instrumental Rock,Ethnic,\ \Gothic,Darkwave,Techno-Industrial,Electronic,Pop-Folk,\ \Eurodance,Dream,Southern Rock,Comedy,Cult,Gangsta,Top 40,\ \Christian Rap,Pop/Funk,Jungle,Native US,Cabaret,New Wave,\ \Psychadelic,Rave,Showtunes,Trailer,Lo-Fi,Tribal,Acid Punk,\ \Acid Jazz,Polka,Retro,Musical,Rock & Roll,Hard Rock,Folk,\ \Folk-Rock,National Folk,Swing,Fast Fusion,Bebob,Latin,\ \Revival,Celtic,Bluegrass,Avantgarde,Gothic Rock,\ \Progressive Rock,Psychedelic Rock,Symphonic Rock,Slow Rock,\ \Big Band,Chorus,Easy Listening,Acoustic,Humour,Speech,\ \Chanson,Opera,Chamber Music,Sonata,Symphony,Booty Bass,\ \Primus,Porn Groove,Satire,Slow Jam,Club,Tango,Samba,Folklore,\ \Ballad,Power Ballad,Rhythmic Soul,Freestyle,Duet,Punk Rock,\ \Drum Solo,Acapella,Euro-House,Dance Hall,Goa,Drum & Bass,\ \Club - House,Hardcore,Terror,Indie,BritPop,Negerpunk,\ \Polsk Punk,Beat,Christian Gangsta Rap,Heavy Metal,\ \Black Metal,Crossover,Contemporary Christian,Christian Rock,\ \Merengue,Salsa,Thrash Metal,Anime,JPop,Synthpop" !! c
Since each field of the ID3 tag has a fixed length, we don’t need to write a full parser. Instead, we split the last 128 bytes of an mp3 file in the correct places and assemble the tag from that. A bit of extra work is needed (handling null-terminated strings and the optional track number), but it’s nothing complicated.
id3v1 :: B.ByteString -> Maybe ID3v1 id3v1 mp3 = if hdr /= "TAG" then Nothing else Just $ ID3v1 (nts title) (nts artist) (nts album) (maybeRead year) (nts comment) track (genre $ fromEnum gnr) where [hdr, title, artist, album, year, cmt, [zbt], [trk], [gnr]] = splitPlaces [3,30,30,30,4,28,1,1,1] . unpack $ B.drop (B.length mp3 - 128) mp3 nts = takeWhile (> '\NUL') (comment, track) | zbt == '\NUL' = (cmt, Just $ fromEnum trk) | otherwise = (cmt ++ [zbt, trk], Nothing)
Testing reveals that everything is working properly:
main :: IO () main = print . id3v1 =<< B.readFile "G:/music/metal/sabaton/40-1.mp3"
Just (ID3v1 "40 : 1" "SABATON" "The Art of War" (Just 2008) "" (Just 4) "Metal")
12 lines of code, 24 lines of data. Not too terrible.