music-performance 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.document +3 -0
- data/.gitignore +7 -0
- data/.rspec +1 -0
- data/.ruby-version +1 -0
- data/.yardopts +1 -0
- data/ChangeLog.rdoc +5 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +20 -0
- data/README.rdoc +28 -0
- data/Rakefile +54 -0
- data/bin/midify +61 -0
- data/lib/music-performance.rb +25 -0
- data/lib/music-performance/arrangement/midi/midi_events.rb +9 -0
- data/lib/music-performance/arrangement/midi/midi_util.rb +38 -0
- data/lib/music-performance/arrangement/midi/part_sequencer.rb +121 -0
- data/lib/music-performance/arrangement/midi/score_sequencer.rb +33 -0
- data/lib/music-performance/conversion/glissando_converter.rb +36 -0
- data/lib/music-performance/conversion/note_sequence_extractor.rb +100 -0
- data/lib/music-performance/conversion/note_time_converter.rb +76 -0
- data/lib/music-performance/conversion/portamento_converter.rb +26 -0
- data/lib/music-performance/conversion/score_collator.rb +121 -0
- data/lib/music-performance/conversion/score_time_converter.rb +112 -0
- data/lib/music-performance/model/note_attacks.rb +21 -0
- data/lib/music-performance/model/note_sequence.rb +113 -0
- data/lib/music-performance/util/interpolation.rb +18 -0
- data/lib/music-performance/util/note_linker.rb +30 -0
- data/lib/music-performance/util/optimization.rb +33 -0
- data/lib/music-performance/util/piecewise_function.rb +124 -0
- data/lib/music-performance/util/value_computer.rb +172 -0
- data/lib/music-performance/version.rb +7 -0
- data/music-performance.gemspec +33 -0
- data/spec/conversion/glissando_converter_spec.rb +93 -0
- data/spec/conversion/note_sequence_extractor_spec.rb +230 -0
- data/spec/conversion/note_time_converter_spec.rb +96 -0
- data/spec/conversion/portamento_converter_spec.rb +91 -0
- data/spec/conversion/score_collator_spec.rb +136 -0
- data/spec/conversion/score_time_converter_spec.rb +73 -0
- data/spec/model/note_sequence_spec.rb +147 -0
- data/spec/music-performance_spec.rb +7 -0
- data/spec/spec_helper.rb +8 -0
- data/spec/util/note_linker_spec.rb +68 -0
- data/spec/util/optimization_spec.rb +73 -0
- data/spec/util/value_computer_spec.rb +146 -0
- metadata +242 -0
@@ -0,0 +1,100 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
class NoteSequenceExtractor
|
5
|
+
# For all link types:
|
6
|
+
# - Remove link where source pitch is not in current note
|
7
|
+
# For tie:
|
8
|
+
# - Remove any bad tie (where the tying pitch does not exist in the next note).
|
9
|
+
# - Replace any good tie with a slur.
|
10
|
+
# For slur/legato:
|
11
|
+
# - Remove any bad link (where the target pitch does not exist in the next note).
|
12
|
+
# TODO: what to do about multiple links that target the same pitch?
|
13
|
+
def self.fixup_links note, next_note
|
14
|
+
note.links.each do |pitch, link|
|
15
|
+
if !note.pitches.include?(pitch)
|
16
|
+
note.links.delete pitch
|
17
|
+
elsif link.is_a? Music::Transcription::Link::Tie
|
18
|
+
if next_note.pitches.include? pitch
|
19
|
+
note.links[pitch] = Music::Transcription::Link::Slur.new(pitch)
|
20
|
+
else
|
21
|
+
note.links.delete pitch
|
22
|
+
end
|
23
|
+
elsif (link.is_a?(Music::Transcription::Link::Slur) ||
|
24
|
+
link.is_a?(Music::Transcription::Link::Legato))
|
25
|
+
unless next_note.pitches.include? link.target_pitch
|
26
|
+
note.links.delete pitch
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.replace_articulation note, next_note
|
33
|
+
case note.articulation
|
34
|
+
when Music::Transcription::Articulations::SLUR
|
35
|
+
NoteLinker.fully_link(note, next_note, Music::Transcription::Link::Slur)
|
36
|
+
note.articulation = Music::Transcription::Articulations::NORMAL
|
37
|
+
when Music::Transcription::Articulations::LEGATO
|
38
|
+
NoteLinker.fully_link(note, next_note, Music::Transcription::Link::Legato)
|
39
|
+
note.articulation = Music::Transcription::Articulations::NORMAL
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
attr_reader :notes
|
44
|
+
def initialize notes, cents_per_step = 10
|
45
|
+
@cents_per_step = cents_per_step
|
46
|
+
@notes = notes.map {|n| n.clone }
|
47
|
+
|
48
|
+
@notes.push Music::Transcription::Note.quarter
|
49
|
+
(@notes.size-1).times do |i|
|
50
|
+
NoteSequenceExtractor.fixup_links(@notes[i], @notes[i+1])
|
51
|
+
NoteSequenceExtractor.replace_articulation(@notes[i], @notes[i+1])
|
52
|
+
end
|
53
|
+
@notes.pop
|
54
|
+
end
|
55
|
+
|
56
|
+
def extract_sequences
|
57
|
+
sequences = []
|
58
|
+
offset = 0
|
59
|
+
|
60
|
+
@notes.each_index do |i|
|
61
|
+
while @notes[i].pitches.any?
|
62
|
+
elements = []
|
63
|
+
|
64
|
+
j = i
|
65
|
+
loop do
|
66
|
+
note = @notes[j]
|
67
|
+
pitch = note.pitches.pop
|
68
|
+
dur = note.duration
|
69
|
+
accented = note.accented
|
70
|
+
link = note.links[pitch]
|
71
|
+
|
72
|
+
case link
|
73
|
+
when Music::Transcription::Link::Slur
|
74
|
+
elements.push(SlurredElement.new(dur, pitch, accented))
|
75
|
+
when Music::Transcription::Link::Legato
|
76
|
+
elements.push(LegatoElement.new(dur, pitch, accented))
|
77
|
+
when Music::Transcription::Link::Glissando
|
78
|
+
elements += GlissandoConverter.glissando_elements(pitch, link.target_pitch, dur, accented)
|
79
|
+
when Music::Transcription::Link::Portamento
|
80
|
+
elements += PortamentoConverter.portamento_elements(pitch, link.target_pitch, @cents_per_step, dur, accented)
|
81
|
+
else
|
82
|
+
elements.push(FinalElement.new(dur, pitch, accented, note.articulation))
|
83
|
+
break
|
84
|
+
end
|
85
|
+
|
86
|
+
j += 1
|
87
|
+
break if j >= @notes.size || !@notes[j].pitches.include?(link.target_pitch)
|
88
|
+
end
|
89
|
+
|
90
|
+
sequences.push(NoteSequence.from_elements(offset,elements))
|
91
|
+
end
|
92
|
+
offset += @notes[i].duration
|
93
|
+
end
|
94
|
+
|
95
|
+
return sequences
|
96
|
+
end
|
97
|
+
end
|
98
|
+
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,76 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
# Convert note duration to time duration.
|
5
|
+
class NoteTimeConverter
|
6
|
+
|
7
|
+
def initialize tempo_computer, beat_duration_computer, sample_rate
|
8
|
+
@tempo_computer = tempo_computer
|
9
|
+
@beat_duration_computer = beat_duration_computer
|
10
|
+
@sample_period = Rational(1,sample_rate)
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.notes_per_second tempo, beat_duration
|
14
|
+
(tempo * beat_duration) * Rational(1,60)
|
15
|
+
end
|
16
|
+
|
17
|
+
def notes_per_second_at offset
|
18
|
+
tempo = @tempo_computer.value_at offset
|
19
|
+
beat_duration = @beat_duration_computer.value_at offset
|
20
|
+
return NoteTimeConverter.notes_per_second(tempo,beat_duration)
|
21
|
+
end
|
22
|
+
|
23
|
+
# Convert the given note duration to a time duration. Using the tempo computer
|
24
|
+
# and beat duration computer, figure the current notes-per-second relationship
|
25
|
+
# depending on the current note offset. Using this, note duration for each
|
26
|
+
# sample is known and accumulated as samples are taken. When accumulated note
|
27
|
+
# duration passes the given desired duration (note_end - note_begin), the
|
28
|
+
# number of samples take will indicated the corresponding time duration. There
|
29
|
+
# is adjustment for last sample taken, which likely goes past the desired note
|
30
|
+
# duration.
|
31
|
+
#
|
32
|
+
# @param [Numeric] note_begin the starting note offset.
|
33
|
+
# @param [Numeric] note_end the ending note offset.
|
34
|
+
# @raise [ArgumentError] if note end is less than note begin.
|
35
|
+
def time_elapsed note_begin, note_end
|
36
|
+
raise ArgumentError "note end is less than note begin" if note_end < note_begin
|
37
|
+
|
38
|
+
time = 0.to_r
|
39
|
+
note = note_begin
|
40
|
+
|
41
|
+
while note < note_end
|
42
|
+
notes_per_sec = notes_per_second_at note
|
43
|
+
notes_per_sample = notes_per_sec * @sample_period
|
44
|
+
|
45
|
+
if (note + notes_per_sample) > note_end
|
46
|
+
#interpolate between note and note_end
|
47
|
+
perc = (note_end - note) / notes_per_sample
|
48
|
+
time += @sample_period * perc
|
49
|
+
note = note_end
|
50
|
+
else
|
51
|
+
time += @sample_period
|
52
|
+
note += notes_per_sample
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
return time
|
57
|
+
end
|
58
|
+
|
59
|
+
#map absolute note offsets to relative time offsets
|
60
|
+
def map_note_offsets_to_time_offsets note_offsets
|
61
|
+
time_counter = 0.0
|
62
|
+
sorted_offsets = note_offsets.sort
|
63
|
+
note_time_map = { sorted_offsets.first => time_counter }
|
64
|
+
|
65
|
+
for i in 1...sorted_offsets.count do
|
66
|
+
time_counter += time_elapsed(sorted_offsets[i-1], sorted_offsets[i])
|
67
|
+
note_time_map[sorted_offsets[i]] = time_counter
|
68
|
+
end
|
69
|
+
|
70
|
+
return note_time_map
|
71
|
+
end
|
72
|
+
|
73
|
+
end
|
74
|
+
|
75
|
+
end
|
76
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
class PortamentoConverter
|
5
|
+
def self.portamento_pitches(start_pitch, target_pitch, cents_per_step)
|
6
|
+
start, finish = start_pitch.total_cents, target_pitch.total_cents
|
7
|
+
step_size = finish >= start ? cents_per_step : -cents_per_step
|
8
|
+
nsteps = ((finish - 1 - start) / step_size.to_f).ceil
|
9
|
+
cents = Array.new(nsteps+1){|i| start + i * step_size }
|
10
|
+
|
11
|
+
cents.map do |cent|
|
12
|
+
Music::Transcription::Pitch.new(cent: cent)
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.portamento_elements(start_pitch, target_pitch, cents_per_step, duration, accented)
|
17
|
+
pitches = portamento_pitches(start_pitch, target_pitch, cents_per_step)
|
18
|
+
subdur = Rational(duration, pitches.size)
|
19
|
+
pitches.map do |pitch|
|
20
|
+
SlurredElement.new(subdur, pitch, accented)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
end
|
@@ -0,0 +1,121 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
class ScoreNotValidError < StandardError; end
|
5
|
+
|
6
|
+
# Combine multiple program segments to one, using tempo/note/dynamic
|
7
|
+
# replication and truncation where necessary.
|
8
|
+
class ScoreCollator
|
9
|
+
def initialize score
|
10
|
+
unless score.valid?
|
11
|
+
raise ScoreNotValidError, "errors found in score: #{score.errors}"
|
12
|
+
end
|
13
|
+
@score = score
|
14
|
+
end
|
15
|
+
|
16
|
+
def collate_parts
|
17
|
+
segments = @score.program.segments
|
18
|
+
|
19
|
+
Hash[
|
20
|
+
@score.parts.map do |name, part|
|
21
|
+
new_dcs = collate_changes(part.start_dynamic,
|
22
|
+
part.dynamic_changes, segments)
|
23
|
+
new_notes = collate_notes(part.notes, segments)
|
24
|
+
new_part = Music::Transcription::Part.new(part.start_dynamic,
|
25
|
+
dynamic_changes: new_dcs, notes: new_notes)
|
26
|
+
[ name, new_part ]
|
27
|
+
end
|
28
|
+
]
|
29
|
+
end
|
30
|
+
|
31
|
+
def collate_tempo_changes
|
32
|
+
collate_changes(@score.start_tempo,
|
33
|
+
@score.tempo_changes, @score.program.segments)
|
34
|
+
end
|
35
|
+
|
36
|
+
def collate_meter_changes
|
37
|
+
collate_changes(@score.start_meter,
|
38
|
+
@score.meter_changes, @score.program.segments)
|
39
|
+
end
|
40
|
+
|
41
|
+
private
|
42
|
+
|
43
|
+
def collate_changes start_value, changes, program_segments
|
44
|
+
new_changes = {}
|
45
|
+
comp = ValueComputer.new(start_value,changes)
|
46
|
+
segment_start_offset = 0.to_r
|
47
|
+
|
48
|
+
program_segments.each do |seg|
|
49
|
+
included = changes.select {|offset,change| offset > seg.first && offset < seg.last }
|
50
|
+
included.each do |offset, change|
|
51
|
+
if(offset + change.duration) > seg.last
|
52
|
+
change.duration = seg.last - offset
|
53
|
+
change.value = comp.value_at seg.last
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# find & add segment start value first
|
58
|
+
value = comp.value_at seg.first
|
59
|
+
offset = segment_start_offset
|
60
|
+
new_changes[offset] = Music::Transcription::Change::Immediate.new(value)
|
61
|
+
|
62
|
+
# add changes to part, adjusting for segment start offset
|
63
|
+
included.each do |offset2, change|
|
64
|
+
offset3 = (offset2 - seg.first) + segment_start_offset
|
65
|
+
new_changes[offset3] = change
|
66
|
+
end
|
67
|
+
|
68
|
+
segment_start_offset += (seg.last - seg.first)
|
69
|
+
end
|
70
|
+
|
71
|
+
return new_changes
|
72
|
+
end
|
73
|
+
|
74
|
+
def collate_notes notes, program_segments
|
75
|
+
new_notes = []
|
76
|
+
program_segments.each do |seg|
|
77
|
+
cur_offset = 0
|
78
|
+
cur_notes = []
|
79
|
+
|
80
|
+
l = 0
|
81
|
+
while cur_offset < seg.first && l < notes.size
|
82
|
+
cur_offset += notes[l].duration
|
83
|
+
l += 1
|
84
|
+
end
|
85
|
+
|
86
|
+
pre_remainder = cur_offset - seg.first
|
87
|
+
if pre_remainder > 0
|
88
|
+
cur_notes << Music::Transcription::Note.new(pre_remainder)
|
89
|
+
end
|
90
|
+
|
91
|
+
# found some notes to add...
|
92
|
+
if l < notes.size
|
93
|
+
r = l
|
94
|
+
while cur_offset < seg.last && r < notes.size
|
95
|
+
cur_offset += notes[r].duration
|
96
|
+
r += 1
|
97
|
+
end
|
98
|
+
|
99
|
+
cur_notes += Marshal.load(Marshal.dump(notes[l...r]))
|
100
|
+
overshoot = cur_offset - seg.last
|
101
|
+
if overshoot > 0
|
102
|
+
cur_notes[-1].duration -= overshoot
|
103
|
+
cur_offset = seg.last
|
104
|
+
end
|
105
|
+
|
106
|
+
cur_notes[-1].links.clear
|
107
|
+
end
|
108
|
+
|
109
|
+
post_remainder = seg.last - cur_offset
|
110
|
+
if post_remainder > 0
|
111
|
+
cur_notes << Music::Transcription::Note.new(post_remainder)
|
112
|
+
end
|
113
|
+
|
114
|
+
new_notes.concat cur_notes
|
115
|
+
end
|
116
|
+
return new_notes
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
end
|
121
|
+
end
|
@@ -0,0 +1,112 @@
|
|
1
|
+
require 'set'
|
2
|
+
|
3
|
+
module Music
|
4
|
+
module Performance
|
5
|
+
|
6
|
+
# Utility class to convert a score from note-based to time-based offsets
|
7
|
+
class ScoreTimeConverter
|
8
|
+
|
9
|
+
def initialize score, sample_rate
|
10
|
+
tempo_computer = ValueComputer.new(
|
11
|
+
score.start_tempo, score.tempo_changes)
|
12
|
+
|
13
|
+
bdcs = Hash[ score.meter_changes.map do |offset,change|
|
14
|
+
newchange = change.clone
|
15
|
+
newchange.value = change.value.beat_duration
|
16
|
+
[offset, newchange]
|
17
|
+
end ]
|
18
|
+
beat_duration_computer = ValueComputer.new(
|
19
|
+
score.start_meter.beat_duration, bdcs)
|
20
|
+
|
21
|
+
@note_time_converter = NoteTimeConverter.new(
|
22
|
+
tempo_computer, beat_duration_computer, sample_rate)
|
23
|
+
@score = score
|
24
|
+
@note_time_map = make_note_time_map(gather_all_offsets)
|
25
|
+
end
|
26
|
+
|
27
|
+
# Convert note-based offsets & durations to time-based.
|
28
|
+
def convert_parts
|
29
|
+
newparts = {}
|
30
|
+
@score.parts.each do |name,part|
|
31
|
+
offset = 0
|
32
|
+
|
33
|
+
newnotes = part.notes.map do |note|
|
34
|
+
starttime = @note_time_map[offset]
|
35
|
+
endtime = @note_time_map[offset + note.duration]
|
36
|
+
offset += note.duration
|
37
|
+
newnote = note.clone
|
38
|
+
newnote.duration = endtime - starttime
|
39
|
+
newnote
|
40
|
+
end
|
41
|
+
|
42
|
+
new_dcs = Hash[
|
43
|
+
part.dynamic_changes.map do |offset, change|
|
44
|
+
timeoffset = @note_time_map[offset]
|
45
|
+
newchange = change.clone
|
46
|
+
newchange.duration = @note_time_map[offset + change.duration]
|
47
|
+
|
48
|
+
[timeoffset,newchange]
|
49
|
+
end
|
50
|
+
]
|
51
|
+
|
52
|
+
newparts[name] = Music::Transcription::Part.new(
|
53
|
+
part.start_dynamic,
|
54
|
+
notes: newnotes,
|
55
|
+
dynamic_changes: new_dcs
|
56
|
+
)
|
57
|
+
end
|
58
|
+
return newparts
|
59
|
+
end
|
60
|
+
|
61
|
+
# Convert note-based offsets & durations to time-based.
|
62
|
+
def convert_program
|
63
|
+
newsegments = @score.program.segments.map do |segment|
|
64
|
+
first = @note_time_map[segment.first]
|
65
|
+
last = @note_time_map[segment.last]
|
66
|
+
first...last
|
67
|
+
end
|
68
|
+
Program.new(newsegments)
|
69
|
+
end
|
70
|
+
|
71
|
+
private
|
72
|
+
|
73
|
+
def gather_all_offsets
|
74
|
+
note_offsets = Set.new [0]
|
75
|
+
|
76
|
+
@score.parts.each do |name, part|
|
77
|
+
offset = 0.to_r
|
78
|
+
part.notes.each do |note|
|
79
|
+
offset += note.duration
|
80
|
+
note_offsets << offset
|
81
|
+
end
|
82
|
+
|
83
|
+
part.dynamic_changes.each do |change_offset, change|
|
84
|
+
note_offsets << change_offset
|
85
|
+
note_offsets << (change_offset + change.duration)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
@score.program.segments.each do |segment|
|
90
|
+
note_offsets << segment.first
|
91
|
+
note_offsets << segment.last
|
92
|
+
end
|
93
|
+
|
94
|
+
return note_offsets
|
95
|
+
end
|
96
|
+
|
97
|
+
def make_note_time_map note_offsets
|
98
|
+
return @note_time_converter.map_note_offsets_to_time_offsets note_offsets
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
end
|
103
|
+
|
104
|
+
module Transcription
|
105
|
+
class Score
|
106
|
+
def to_time_score sample_rate
|
107
|
+
ScoreTimeConverter.new(self,sample_rate).make_time_score
|
108
|
+
end
|
109
|
+
end
|
110
|
+
end
|
111
|
+
|
112
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
require 'singleton'
|
5
|
+
class AccentedAttack
|
6
|
+
include Singleton
|
7
|
+
|
8
|
+
def accented?; return true; end
|
9
|
+
end
|
10
|
+
|
11
|
+
class UnaccentedAttack
|
12
|
+
include Singleton
|
13
|
+
|
14
|
+
def accented?; return false; end
|
15
|
+
end
|
16
|
+
|
17
|
+
ACCENTED = AccentedAttack.instance
|
18
|
+
UNACCENTED = UnaccentedAttack.instance
|
19
|
+
|
20
|
+
end
|
21
|
+
end
|