Putting aside the musical theme for a moment, this quiz boiled down to a
text processing problem, complicated slightly by the fact that tab
formats are actually quite variable, and often input will contain text
mixed with valid tab. This required the solutions to be liberal in what
they would accept, and conservative in their output - something that all
the solutions achieved in different ways. Let's look first at the Tab
class from Anthony Moralez's solution:
class Tab
def initialize(tab_file)
@chords = []
@file = tab_file
@tab = extract_tabs(tab_file)
end
#select only lines containing tab notation and remove extraneous chars
def extract_tabs(file)
File.readlines(file).select { |line|
line =~ /[eBGDAE|-][|-]/
}.collect { |line|
line.gsub(/[eBGDAE|\s]/, '')
}
end
We can already see how Anthony is handling plain-text in the tab files -
lines are selected from the input using a regular expression, looking
for the string note at the start of a tab line, followed by tab
characters. Failing that, two consecutive -- are accepted. This is a
trade-off on Anthony's part: many tabs omit the string notes on some or
all of the tablature, and Anthony's solution will still pick that up.
However, it may also pick up some extraneous lines, such as message
headers and the like.
The selected lines are then massaged using gsub, to remove any
extraneous characters (those string notes, spaces, and bars).
Adam Shelly took a different approach. His code is based around a main
loop, which loops through the input file looking for runs of six
consecutive lines of equal length. Here's the first part of that loop:
until ARGF.eof do
lines << ARGF.gets.chomp.split('');
#read until we find 6 lines of same length
if lastlength and lastlength != lines[-1].length
#throw away nonmatching lines
lines.shift while lines.size > 1
end
lastlength = lines[-1].length
This is a clever way to approach the problem, and I think it will
reliably find most tabs in a given file. There are still potential false
positive matches (an ASCII table over six lines, for example) but those
are probably relatively few and far between. Here's the rest of the
loop, which converts the notes to the guitar's expected format, and does
the actual playing:
if lines.size == 6
sig = lines.inject([]){|a,l| a <<l.shift}
#make sure it has a key signature
if !sig.find{|e| !e or !(("A".."G").include?(e.upcase))}
#create a guitar in the key of the first tab found.
g ||= Guitar.new(Guitar::NYLON_ACOUSTIC,
tuningMap[sig.reverse.join.upcase])
until (lines[0].empty?)
note = lines.inject([]){|a,l| a << l.shift}
if (note[0]!='|')
p note.join if $DEBUG
g.play(note.join)
end
end
end
lines.clear
end
end
This runs every time the buffer gets to six equal-length lines, and
handles both conversion and playing. Firstly, Adam shifts the first
character from each tab line and stores them in an array - this will be
the string note at the start of the line. These are then checked to make
sure they all fall within the valid range of notes (A to G), and if so
they are used to select a tuning for the guitar. Adam then steps through
the tab lines, using inject to shift the first character from each line,
and putting them together to make the notes passed into the guitar.
(Incidentally, Adam's solution seems to be designed for a left-handed
guitar, since it passes the notes to the guitar in reverse order).
These solutions, and most of the others, used the guitar provided with
the quiz, and as such are subject to the limitations mentioned in the
quiz. As it turned out, extending the guitar entailed more work than
first appeared, but Douglas Seifert had a go, removing that nine-fret
limitation and providing the ability to vary the note type that was
played. To achieve this, Douglas changed this:
d = @seq.note_to_delta(@note)
notes.split(//).each_with_index do |fret, channel|
...
end
to (some whitespace and comments edited):
md = /(\w):(.+)/.match(notes)
notetype = @notes[md[1]]
d = @seq.note_to_delta(notetype)
md[2].split('|').each_with_index do |fret, channel|
...
end
This changes the guitar's expected input format, but that's probably
unavoidable if we want to support more than single digit frets. Douglas
kindly explains the new format in his comments:
# Play some notes on the guitar. Pass notes in this notation:
#
# "n:6|5|4|3|2|1"
# first char is the note type (sixteenth, eighth, quarter, etc) followed
# by a colon and then the frets for each string (pipe '|' separates
# fret number for each string)
I don't want to get too technical here, but for those with no musical
background the different note types represent the duration of the note,
as a fraction of a beat. In real music different note values are often
mixed, so this addition potentially allows us to play a wider range of
music, given the extra information in the original tab.
Douglas' tab parsing code is very well commented and easy to follow, so
I recommend taking a look for the full lowdown, but let's focus here on
the section of code that handles those awkward two-digit fret numbers:
while i < num_eighths - 1
chord = ''
max_number_length = 1
# Figure out the chord ... it will be of the form 1|2|3|5|4|3|
# two passes to handle alignment issues with two digit and one
# digit notes... some tabs line them up on the first digit, yet
# others line them up on the last digit. This algorithm only
# handles up to two consecutive digits for a note.
ss.size().downto(1) { |s|
this_max_number_len = 1
# First case here is trying to deal with two digit numbers
if ss[s-1][i].chr != "x" && ss[s-1][i+1].chr != "x"
this_max_number_len += 1
end
# Save the size of the maximum string of numbers for later
if this_max_number_len > max_number_length
max_number_length = this_max_number_len
end
}
# Second pass, we know the max consecutive digits, either 1 or 2
ss.size().downto(1) { |s|
# First case handles single digit lined up on the right
if max_number_length > 1 && ss[s-1][i].chr == "x" && ss[s-1][i+1].chr != "x"
chord << ss[s-1][i+1]
# Second case handles two digit notes
elsif ss[s-1][i].chr != "x" && ss[s-1][i+1].chr != "x"
chord << ss[s-1][i]
chord << ss[s-1][i+1]
# single digit notes lined up on left
else
chord << ss[s-1][i]
end
chord << "|"
}
# Keep track of number of consecutive empty chords for poor man's timing
if chord == empty_chord
if delay_index + 1 < @@notes.length()
delay_index += 1
end
else
if delay_index == -1
delay_index = 0
end
# get rid of the last pipe
chord.chomp!("|")
# Modified guitar wants the note in new format. First char indicates the
# delay that passed before current note. After colon, we have pipe
# delimited note values for each string
axe.play("#{@@notes[delay_index]}:#{chord}")
# reset the consecutive empty chords counter
delay_index = -1
end
# skip past multiple digit notes
i += max_number_length
end
The comments tell the story very well here, so I'll just wrap up by
drawing your attention to the way Douglas handles the varying tab style
used by different authors, with special cases to handle double-digits
aligned both left and right with the rest of the notes, and also the way
empty chords are tracked and used to select a duration for the following
note. This adds an interesting variability to the speed and style with
which tabs are played. Coupled with the support for double-digit fret
numbers, this gave a very nice sound, especially with the included tab
for Metallica's 'Nothing else matters'.
This summary could go on forever, with all the submitted solutions
providing some interesting points, but I'm aware that this is already
running rather long. I would definitely urge you take a look at the
solutions in full, and especially to run them and listen to their output
- every one of them has it's own sound, and one of the great things
about music is that it's never really 'right' or 'wrong'. Thanks
everyone who took the time to play with this!
Before I go, just a quick reminder that there will be no quiz tomorrow,
as James is still on Holiday (and hopefully having a fine time), so
maybe it'd be a good week to write up those Ruby Quiz suggestions you've
been meaning to get around to. Don't forget there's another copy of Best
Of Ruby Quiz up for grabs and trust me, it's definitely worth having
(mine arrived yesterday :)).