lib/midi_lyrics.rb
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