ID3 Tags (#136)

This quiz was another idea I got out of the Erlang book. The author
uses a
similar example to show how smooth processing binary data in Erlang can
be. I’m
happy to say that I found the submitted Ruby solutions to be equally
smooth, if
not more so.

The secret to binary parsing in Ruby is generally the String.unpack()
method and
the majority of the solutions capitalized on this technique.
Technically, ID3
tags are mainly in plain text, with some null characters thrown in.
Still, I
think it’s a good idea to get into the unpack() mindset anytime you
start
slicing up binary data.

I want to take a look at Eugene K.'s code below. It’s a pretty
typical
usage of unpack() to parse some data. It also includes a nicety when
reading
the file that I’m ashamed to admit I didn’t think of. Let’s start with
that:

def fileTail (file, offset)
f=File.new(file)
f.seek(-offset,IO::SEEK_END)
f.read
end

In my own code, I read the whole file into memory and indexed out the
last 128
bytes. That’s almost always the wrong approach and Eugene shows the
correct
strategy above. This code just opens the file, seek()s to offset bytes
before
the end, and read()s the needed data. That scales much better when the
data
sizes are significant.

As a quick aside, file_tail() would probably be a more Rubyish method
name.

The code now builds a data structure class to hold the tag details. It
starts
like this:

class ID3Tag
GENRES=[“Blues”,“Classic Rock”,“Country”,…,“Dance Hall”]
attr_reader :title, :artist, :album, :year, :comment, :genre, :track

# ...

You can see that this class is mainly just a data structure that defines
readers
for all of the elements in a tag. I’ve trimmed the GENRES listing here,
but the
code included the full set.

I will say that some found more clever means to load the GENRES Array.
Several
people did fancy heredoc manipulations, but the most clever pulled the
list out
of the quiz document using open-uri and hpricot. That was especially
wise this
time since I made so many mistakes in the quiz description.

We’re now ready for the actual parsing code:

# ...

def initialize fname
  tag,@title,@artist,@album,@year,@comment,@genre=
    fileTail(fname,128).unpack "A3A30A30A30A4A30C"
  raise "No ID3 Info" if tag!='TAG'
  s_com,flag,[email protected] "A28CC"
  if flag==0 and track!=0
    @comment=s_com
    @track=track
  end
  @genre=GENRES[@genre]
  @genre="Unknown" if  !@genre
end

end

As you can see, the majority of the work is done on the first line with
a single
call to unpack(). The template fed to unpack() is the key to the whole
puzzle.
An “A” in the unpack() template instructs it to extract a String,
removing any
trailing spaces or null characters. By default the String is just one
character
long, but you can provide a number after the “A” to increase that count.
The
only other character used in the template is a “C” which is used to
extract one
character as an Integer. The unpack() call returns an Array which
Eugene just
mass-assigns to the relevant variables.

The rest is simple. The code checks the first chunk for the identifying
“TAG”
String and throws an error if it’s not there. Then another call to
unpack(),
with a template much like the first, pulls the track field out of the
comment.
The if statement makes sure that assignment only happens when it is
present.
The final two lines are just a longhand form of:

@genre = GENRES[@genre] || “Unknown”

With all of the fields stored away in the proper variables, reader calls
can be
used to extract as needed. Eugene’s actual application code just punted
on that
point though:

p ID3Tag.new(ARGV[0])

My thanks to all who have helped me with my Erlang comparisons these
last two
weeks. I promise, we’re on to new topics now.

In fact, tomorrow we will tackle an interesting subproblem from this
year’s ICFP
contest…