Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bpm #29

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open

Bpm #29

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added examples/ex2.mid
Binary file not shown.
44 changes: 36 additions & 8 deletions lib/midilib/sequence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Sequence

UNNAMED = 'Unnamed Sequence'
DEFAULT_TEMPO = 120
BPM_ROUND = 3

NOTE_TO_LENGTH = {
'whole' => 4.0,
Expand Down Expand Up @@ -62,21 +63,32 @@ def time_signature(numer, denom, clocks, qnotes)
@qnotes = qnotes
end

# Returns the song tempo in beats per minute.
def beats_per_minute
# Returns the song tempo in beats per minute, nil if time_from_start is out of range
def beats_per_minute(time_from_start = 0)
return DEFAULT_TEMPO if @tracks.nil? || @tracks.empty?

event = @tracks.first.events.detect { |e| e.is_a?(MIDI::Tempo) }
event ? Tempo.mpq_to_bpm(event.tempo) : DEFAULT_TEMPO
return nil if time_from_start > self.get_measures.last.end || time_from_start < 0

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would almost in these cases get the first known tempo and last known tempo instead of returning nil here, such that:

  • If the time offset is greater than the song duration return the last registered tempo
  • If the time offset is less than zero return the first registered tempo

...otherwise downstream code will have to deal with the potential for nil.

current_bpm = DEFAULT_TEMPO
tempo_parts = get_tempo_parts
tempo_parts.each_with_index do |part, i|
if !tempo_parts[i+1].nil?
current_bpm = part[1] if part[0] <= time_from_start && tempo_parts[i+1][0] > time_from_start
else
current_bpm = part[1] if part[0] <= time_from_start
end
end
Copy link

@baweaver baweaver Oct 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This may be easier to work with if you use time ranges below instead, then you might be able to do something like this:

_, found_tempo = tempo_ranges.find { |range, _| range.cover?(time_from_start) }
found_tempo || DEFAULT_TEMPO

...such that:

tempos = { 0..5 => 120, 5..20 => 100, 20..30 => 180 }
time_from_start = 10
_, found_tempo = tempos.find { |range, _| range.cover?(time_from_start) }
found_tempo
# => 100

current_bpm
end

alias bpm beats_per_minute
alias tempo beats_per_minute

# Pulses (also called ticks) are the units of delta times and event
# time_from_start values. This method converts a number of pulses to a
# float value that is a time in seconds.
def pulses_to_seconds(pulses)
(pulses.to_f / @ppqn.to_f / beats_per_minute) * 60.0
# float value that is a time in seconds. Returns nil if time_from_start out of range
def pulses_to_seconds(pulses, time_from_start = 0)
unless beats_per_minute(time_from_start).nil?
(pulses.to_f / @ppqn.to_f / beats_per_minute(time_from_start)) * 60.0
end
end

# Given a note length name like "whole", "dotted quarter", or "8th
Expand Down Expand Up @@ -200,5 +212,21 @@ def get_measures
end
measures
end

private

# Private method to split sequence into parts, if more then one bpm present in sequence
# Returns hash { start_time => bpm }
def get_tempo_parts
tempo_parts = {}
return tempo_parts[0] = DEFAULT_TEMPO if @tracks.nil? || @tracks.empty?
Array(@tracks).each do |track|
track.events.map do |e|
e.is_a?(MIDI::Tempo) ? tempo_parts[e.time_from_start] = Tempo.mpq_to_bpm(e.tempo) : tempo_parts[0] = DEFAULT_TEMPO
end
end
tempo_parts.transform_values! { |bpm| bpm.round(BPM_ROUND) }
end
Copy link

@baweaver baweaver Oct 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Playing with the above idea of using ranges instead:

# New constant to work with for no events / no tempos
DEFAULT_TEMPO_RANGE = { (0..) => DEFAULT_TEMPO }

def get_tempo_parts
  # We can get all the events, then use `grep` to find all of them that are MIDI Tempos
  # and get them into the format we need. Coincidentally if you stop it here it'll do the
  # same as the above.
  tempo_events = (@tracks || [])
    .flat_map(&:events)
    .grep(MIDI::Tempo)
    .map { |t| [t.time_from_start, Tempo.mpq_to_bpm(t.tempo).round(BPM_ROUND)] }

  return DEFAULT_TEMPO_RANGE unless tempo_events.any?

  # We do not have a tempo event at the start
  tempo_events << [0, DEFAULT_TEMPO] if tempo_events.first.first != 0

  tempo_ranges = {}
  # This is a nice trick, group every 2 consecutive tempos
  tempo_events.each_cons(2).each do |first_tempo, second_tempo|
    first_tempo_start, first_tempo_bpm = first_tempo
    second_tempo_start, second_tempo_bpm = second_tempo

    # Note using `...` here which means up to and excluding
    tempo_ranges[first_tempo_start...second_tempo_start] = first_tempo_bpm
  end

  last_tempo_start, last_bpm = tempo_events.last
  tempo_ranges[last_tempo_start..] = last_bpm

  tempo_ranges
end

Though I don't think this necessarily needs to be a Hash.

each_cons is nifty for things like this:

[1,2,3,4].each_cons(2).to_a
=> [[1, 2], [2, 3], [3, 4]]

...along with ... which is "up to but excluding" for Range:

(0...5).cover?(5) # false
(0..5).cover?(5) # true

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Then again there's an edge there on what happens if those tracks have BPMs distinct to them that happen to not be in the same order, but not sure if that even makes sense.


end
end
27 changes: 27 additions & 0 deletions test/test_sequence.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ def setup
@seq.tracks << @track
3.times { @track.events << MIDI::NoteOn.new(0, 64, 64, 100) }
@track.recalc_times
@seq_bpm_diff = MIDI::Sequence.new
end

def test_basics
Expand All @@ -31,6 +32,15 @@ def test_pulses_to_seconds

# An eight note should take 0.25 seconds
assert_in_delta 0.25, @seq.pulses_to_seconds(480 / 2), 0.00001

# At a tempo of 120 BPM 480 pulses (one quarter note) should take 0.5 seconds
assert_in_delta 0.5, @seq.pulses_to_seconds(480, 1000), 0.00001

# Should use offset = 0 if offset is not present
assert_in_delta 0.5, @seq.pulses_to_seconds(480), 0.00001

# Should retun nil if offset is out of range
assert_equal(nil, @seq.pulses_to_seconds(480, 1920))
end

def test_length_to_delta
Expand Down Expand Up @@ -77,4 +87,21 @@ def test_note_to_delta
assert_equal(480 / 16, @seq.note_to_delta('sixtyfourth'))
assert_equal(480 / 16, @seq.note_to_delta('64th'))
end

def test_beats_per_minute
# Using file with 2 different tempos whithin sequence (bpm change at 15600)
File.open('examples/ex2.mid', 'rb') do | file |
@seq_bpm_diff.read(file)
assert_equal(nil, @seq_bpm_diff.beats_per_minute(-1000))
assert_equal(120.0, @seq_bpm_diff.beats_per_minute)
assert_equal(120.0, @seq_bpm_diff.beats_per_minute(15599))
assert_equal(131.34, @seq_bpm_diff.beats_per_minute(15600))
assert_equal(131.34, @seq_bpm_diff.beats_per_minute(15601))
assert_equal(nil, @seq_bpm_diff.beats_per_minute(5000000))
end

# Using regular testing sequence
assert_equal(120.0, @seq.beats_per_minute(1918))
assert_equal(nil, @seq.beats_per_minute(1920))
end
end