-
Notifications
You must be signed in to change notification settings - Fork 34
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
base: main
Are you sure you want to change the base?
Bpm #29
Changes from all commits
71a9b43
bc8896f
3ca44bf
6b0cee5
6cc5a74
2cda21c
c7d5564
ec9c86c
deba8bf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -9,6 +9,7 @@ class Sequence | |
|
||
UNNAMED = 'Unnamed Sequence' | ||
DEFAULT_TEMPO = 120 | ||
BPM_ROUND = 3 | ||
|
||
NOTE_TO_LENGTH = { | ||
'whole' => 4.0, | ||
|
@@ -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 | ||
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
[1,2,3,4].each_cons(2).to_a
=> [[1, 2], [2, 3], [3, 4]] ...along with (0...5).cover?(5) # false
(0..5).cover?(5) # true There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
There was a problem hiding this comment.
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:...otherwise downstream code will have to deal with the potential for
nil
.