mateusdelbianco/midi_lyrics

View on GitHub
lib/midi_lyrics.rb

Summary

Maintainability
A
1 hr
Test Coverage
require "midi_lyrics/version"
require "midilib"

module MidiLyrics
  class FileNotFound < StandardError; end

  class Tempo
    attr_accessor :start, :duration, :tempo

    def initialize(fields = {})
      self.start = fields[:start].to_f
      self.tempo = fields[:tempo].to_f
    end
  end

  class TempoCalculator
    attr_accessor :tempo_track
    attr_accessor :sequence

    def initialize(fields = {})
      self.tempo_track = fields[:tempo_track]
      self.sequence = fields[:sequence]
    end

    def calculate(pulses)
      pulses = pulses.to_f
      value = 0.0
      last_tempo = @tempo_track.first
      @tempo_track.each do |t|
        if t.start < pulses
          value += (((t.start - last_tempo.start) / sequence.ppqn.to_f / ::MIDI::Tempo.mpq_to_bpm(last_tempo.tempo)) * 60.0)
          last_tempo = t
        end
      end
      if last_tempo.start < pulses
        value += (((pulses - last_tempo.start) / sequence.ppqn.to_f / ::MIDI::Tempo.mpq_to_bpm(last_tempo.tempo)) * 60.0)
      end
      value
    end
  end

  class LyricSyllable
    attr_accessor :text, :start_in_pulses, :start2_in_pulses, :duration_in_pulses
    attr_accessor :start, :start2, :duration

    def initialize(fields = {})
      self.start_in_pulses = fields[:start_in_pulses]
      self.start2_in_pulses = fields[:start2_in_pulses]
      self.duration_in_pulses = fields[:duration_in_pulses]
      self.text = fields[:text]
    end

    def end_in_pulses
      start_in_pulses + duration_in_pulses
    end

    def blank?
      text.gsub('-', '').strip == ""
    end

    def similar_to?(another)
      self.duration_in_pulses == another.duration_in_pulses && self.text == another.text
    end

    def as_json
      {
        text: text,
        start: start,
        start2: start2,
        duration: duration
      }
    end
  end

  class Parser
    attr_reader :file, :repeating

    def initialize(file, options = {})
      options = { repeating: false }.merge(options)
      @file = file
      @repeating = options[:repeating]

      unless File.exists?(file)
        raise MidiLyrics::FileNotFound
      end
    end

    def extract
      read_sequence_from_file
      load_tempo_track
      load_tracks
      calculate_durations
      load_lyrics
      remove_heading_blank_lines
      consolidate_empty_syllables
      remove_lines_trailing_spaces
      fix_durations
      remove_repeating unless repeating
      calculate_seconds
      @lyrics.collect(&:as_json)
    end

    private
    def read_sequence_from_file
      @sequence = ::MIDI::Sequence.new()
      File.open(file, "rb") do |file|
        @sequence.read(file)
      end
      @sequence
    end

    def load_tempo_track
      @tempo_track = []

      @sequence.tracks[0].each do |event|
        if event.kind_of?(::MIDI::Tempo)
          @tempo_track << Tempo.new(
            start: event.time_from_start,
            tempo: event.data
          )
        end
      end

      if @tempo_track.size == 0
        @tempo_track << Tempo.new(start: 0, tempo: ::MIDI::Tempo.bpm_to_mpq(120))
      end
    end

    def load_tracks
      @lyrics_track = ::MIDI::Track.new(@sequence)
      @noteon_track = ::MIDI::Track.new(@sequence)

      @sequence.tracks[1].each do |event|
        if event.kind_of?(::MIDI::MetaEvent) && event.meta_type == ::MIDI::META_LYRIC
          @lyrics_track.events << event
        end
        if event.kind_of?(::MIDI::NoteOn)
          @noteon_track.events << event
        end
      end
    end

    def calculate_durations
      @durations = {}
      @noteon_track.each do |event|
        @durations[event.time_from_start] = event.off.time_from_start - event.time_from_start
      end
    end

    def load_lyrics
      @lyrics = []
      @lyrics_track.each do |event|
        event_text = event.data.collect{|x| x.chr(Encoding::UTF_8)}.join
        letters = event_text.gsub(/^[\s-]+|[\s-]+$/, '')

        heading_space = event_text.match(/^([\s-]+)[^[\s-]]/)
        heading_space = heading_space[1] unless heading_space.nil?

        trailing_space = event_text.match(/([\s-]+)$/)
        trailing_space = trailing_space[1] unless trailing_space.nil?

        [heading_space, letters, trailing_space].each do |text|
          unless text.nil?
            @lyrics << LyricSyllable.new(
              start_in_pulses: event.time_from_start,
              duration_in_pulses: @durations[event.time_from_start],
              text: text
            )
          end
        end
      end
    end

    def remove_heading_blank_lines
      while @lyrics.first.blank?
        @lyrics.shift
      end
    end

    def consolidate_empty_syllables
      new_lyrics = []
      @lyrics.each do |l|
        if l.blank?
          if new_lyrics.last.blank?
            new_lyrics.last.text += l.text
          else
            l.start_in_pulses = new_lyrics.last.start_in_pulses + new_lyrics.last.duration_in_pulses
            l.duration_in_pulses = 0.0
            new_lyrics << l
          end
        else
          new_lyrics << l
        end
      end
      @lyrics = new_lyrics
    end

    def remove_lines_trailing_spaces
      @lyrics.each do |l|
        l.text.gsub!(/^[ -]*([\r\n])/, '\1')
      end
    end

    def half_is_equal
      half = @lyrics.count / 2
      (0..(half-1)).each do |x|
        unless @lyrics[x].similar_to?(@lyrics[x + half])
          return false
        end
      end
      return true
    end

    def merge_half_lyrics
      half = @lyrics.count / 2
      (0..(half-1)).collect do |x|
        @lyrics[x].start2_in_pulses = @lyrics.delete_at(half).start_in_pulses
      end
    end

    def remove_repeating
      if half_is_equal
        merge_half_lyrics
      end
    end

    def lyric_starting_at time_in_pulses
      @lyrics.find{ |l| l.duration_in_pulses != 0.0 && l.start_in_pulses == time_in_pulses }
    end

    def fix_durations
      @lyrics.each do |lyric|
        while @durations.has_key?(lyric.end_in_pulses) && lyric_starting_at(lyric.end_in_pulses).nil?
          lyric.duration_in_pulses += @durations[lyric.end_in_pulses]
        end
      end
    end

    def calculate_seconds
      tempo_calculator = TempoCalculator.new(tempo_track: @tempo_track, sequence: @sequence)

      @lyrics.each do |l|
        l.start = tempo_calculator.calculate(l.start_in_pulses).round(3)
        l.start2 = tempo_calculator.calculate(l.start2_in_pulses).round(3)
        l.duration = (tempo_calculator.calculate(l.start_in_pulses + l.duration_in_pulses) - l.start).round(3)
      end
    end
  end
end