class Beats::SongParser

This class is used to parse a raw YAML song definition into domain objects (i.e. Song, Pattern, Track, and Kit). These domain objects can then be used by AudioEngine to generate the actual audio data that is saved to disk.

The sole public method is parse(). It takes a raw YAML string and returns a Song and Kit object (or raises an error if the YAML string couldn't be parsed correctly).

Constants

NO_SONG_HEADER_ERROR_MSG

Public Class Methods

parse(base_path, raw_yaml_string) click to toggle source

Parses a raw YAML song definition and converts it into a Song and Kit object.

# File lib/beats/song_parser.rb, line 12
def self.parse(base_path, raw_yaml_string)
  raw_song_components = hashify_raw_yaml(raw_yaml_string)

  unless raw_song_components[:folder].nil?
    base_path = raw_song_components[:folder]
  end

  song = Song.new
  kit_builder = KitBuilder.new(base_path)

  # Set tempo
  begin
    unless raw_song_components[:tempo].nil?
      song.tempo = raw_song_components[:tempo]
    end
  rescue Song::InvalidTempoError => detail
    raise ParseError, "#{detail}"
  end

  # Add sounds defined in the Kit section
  begin
    add_kit_sounds_from_kit(kit_builder, raw_song_components[:kit])
  rescue KitBuilder::SoundFileNotFoundError => detail
    raise ParseError, "#{detail}"
  rescue KitBuilder::InvalidSoundFormatError => detail
    raise ParseError, "#{detail}"
  end

  # Load patterns
  add_patterns_to_song(song, kit_builder, raw_song_components[:patterns])

  # Set flow
  if raw_song_components[:flow].nil?
    raise ParseError, "Song must have a Flow section in the header."
  else
    set_song_flow(song, raw_song_components[:flow])
  end

  # Swing, if swing flag is set
  if raw_song_components[:swing]
    begin
      song = Transforms::SongSwinger.transform(song, raw_song_components[:swing])
    rescue Transforms::SongSwinger::InvalidSwingRateError => detail
      raise ParseError, "#{detail}"
    end
  end

  # Build the final kit
  begin
    add_kit_sounds_from_patterns(kit_builder, song.patterns)
    kit = kit_builder.build_kit
  rescue KitBuilder::SoundFileNotFoundError => detail
    raise ParseError, "#{detail}"
  rescue KitBuilder::InvalidSoundFormatError => detail
    raise ParseError, "#{detail}"
  end

  return song, kit
end

Private Class Methods

add_kit_sounds_from_kit(kit_builder, raw_kit) click to toggle source
# File lib/beats/song_parser.rb, line 113
def self.add_kit_sounds_from_kit(kit_builder, raw_kit)
  return if raw_kit.nil?

  unless raw_kit.is_a?(Array)
    raise ParseError, "Kit is not an array. Make sure each sound in the Kit is placed on new indented line prefixed with a '-'"
  end

  # Add sounds defined in the Kit section of the song header
  # Converts [{a=>1}, {b=>2}, {c=>3}] from raw YAML to {a=>1, b=>2, c=>3}
  raw_kit.each do |kit_item|
    kit_builder.add_item(kit_item.keys.first, kit_item.values.first)
  end
end
add_kit_sounds_from_patterns(kit_builder, patterns) click to toggle source
# File lib/beats/song_parser.rb, line 127
def self.add_kit_sounds_from_patterns(kit_builder, patterns)
  # Add sounds not defined in Kit section, but used in individual tracks
  patterns.each do |pattern_name, pattern|
    pattern.tracks.each do |track_name, track|
      track_path = track.name

      if !kit_builder.has_label?(track.name)
        kit_builder.add_item(track.name, track_path)
      end
    end
  end
end
add_patterns_to_song(song, kit_builder, raw_patterns) click to toggle source
# File lib/beats/song_parser.rb, line 140
def self.add_patterns_to_song(song, kit_builder, raw_patterns)
  raw_patterns.each do |pattern_name, raw_tracks|
    if !pattern_name.is_a?(String)
      raise ParseError, "Pattern name '#{pattern_name}' is not valid. It must be a value that will be parsed from YAML as a String."
    end

    if raw_tracks.nil?
      # TODO: Possibly allow if pattern not referenced in the Flow, or has 0 repeats?
      raise ParseError, "Pattern '#{pattern_name}' has no tracks. It needs at least one."
    end

    if !raw_tracks.is_a?(Array)
      raise ParseError, "Tracks in pattern '#{pattern_name}' are not an Array. Make sure each track is placed on new indented line prefixed with a '-'"
    end

    tracks = []

    raw_tracks.each_with_index do |raw_track, index|
      if !raw_track.is_a?(Hash)
        raise ParseError, "Track ##{index + 1} in pattern '#{pattern_name}' is incomplete. Must be in form '- <kit/file name>: <rhythm>'"
      end

      track_names = raw_track.keys.first
      rhythm = raw_track.values.first

      # Handle case where no track rhythm is specified (i.e. "- foo.wav:" instead of "- foo.wav: X.X.X.X.")
      rhythm = "" if rhythm.nil?

      track_names = Array(track_names)
      if track_names.empty?
        raise ParseError, "Pattern '#{pattern_name}' uses an empty composite sound (i.e. \"[]\"), which is not valid."
      end

      track_names.map! do |track_name|
        unless track_name.is_a?(String)
          raise ParseError, "'#{track_name}' in pattern '#{pattern_name}' is not a valid filename/kit sound. It must be a value that will be parsed from YAML as a String."
        end
        kit_builder.composite_replacements[track_name] || track_name
      end
      track_names.flatten!

      track_names.each do |track_name|
        tracks << Track.new(track_name, rhythm)
      end
    end

    song.pattern(pattern_name.downcase.to_sym, tracks)
  end
end
downcase_hash_keys(hash) click to toggle source

Converts all hash keys to be lowercase

# File lib/beats/song_parser.rb, line 237
def self.downcase_hash_keys(hash)
  hash.inject({}) do |new_hash, pair|
      new_hash[pair.first.downcase] = pair.last
      new_hash
  end
end
hashify_raw_yaml(raw_yaml_string) click to toggle source
# File lib/beats/song_parser.rb, line 84
def self.hashify_raw_yaml(raw_yaml_string)
  begin
    raw_song_definition = YAML.load(raw_yaml_string)
  rescue Psych::SyntaxError => detail
    raise ParseError, "Syntax error in YAML file: #{detail}"
  end

  header_keys = raw_song_definition.keys.select {|key| key.is_a?(String) && key.downcase == "song" }

  if header_keys.empty?
    raise ParseError, NO_SONG_HEADER_ERROR_MSG
  elsif header_keys.length > 1
    # In theory, this branch should never be reached, due the YAML hash mappings
    # not allowing duplicate keys?
    raise ParseError, "Song has multiple 'Song' sections, it should only have 1."
  else
    header = downcase_hash_keys(raw_song_definition.delete(header_keys.first))
  end

  {
    tempo: header["tempo"],
    folder: header["folder"],
    kit: header["kit"],
    flow: header["flow"],
    swing: header["swing"],
    patterns: raw_song_definition,
  }
end
set_song_flow(song, raw_flow) click to toggle source
# File lib/beats/song_parser.rb, line 191
def self.set_song_flow(song, raw_flow)
  flow = []

  if !raw_flow.is_a?(Array)
    raise ParseError, "Song flow is not an array. Make sure each section of the flow is placed on new indented line prefixed with a '-'"
  end

  raw_flow.each do |pattern_item|
    if !pattern_item.is_a?(Hash)
      if pattern_item.is_a?(String)
        pattern_item = {pattern_item => "x1"}
      else
        raise ParseError, "'#{pattern_item}' is invalid flow section; must be in form '- <pattern name>: <repeat count>'"
      end
    end

    pattern_name = pattern_item.keys.first
    if !pattern_name.is_a?(String)
      raise ParseError, "Pattern name '#{pattern_name}' in flow is not valid. It must be a value that will be parsed from YAML as a String."
    end
    pattern_name_sym = pattern_name.downcase.to_sym

    repeat_count_str = pattern_item[pattern_name]

    unless repeat_count_str.is_a?(String) && repeat_count_str.match(/^x[0-9]+$/) != nil
      raise ParseError,
            "'#{repeat_count_str}' is an invalid number of repeats for pattern '#{pattern_name}'. Number of repeats must be a whole number >= 0, prefixed with 'x'."
    end

    repeat_count = repeat_count_str[1..-1].to_i

    if repeat_count > 0 && !song.patterns.has_key?(pattern_name_sym)
      # This test is purposefully designed to only throw an error if the number of repeats is greater
      # than 0. This allows you to specify an undefined pattern in the flow with "x0" repeats.
      # This can be convenient for defining the flow before all patterns have been added to the song file.
      raise ParseError, "Song flow includes non-existent pattern: '#{pattern_name}'"
    end

    repeat_count.times { flow << pattern_name_sym }
  end

  song.flow = flow
end