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,113 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
SlurredElement = Struct.new(:duration, :pitch, :accented) do
|
5
|
+
def slurred?; true; end
|
6
|
+
def articulation; Music::Transcription::Articulations::NORMAL; end
|
7
|
+
def accented?; accented; end
|
8
|
+
end
|
9
|
+
|
10
|
+
LegatoElement = Struct.new(:duration, :pitch, :accented) do
|
11
|
+
def slurred?; false; end
|
12
|
+
def articulation; Music::Transcription::Articulations::NORMAL; end
|
13
|
+
def accented?; accented; end
|
14
|
+
end
|
15
|
+
|
16
|
+
FinalElement = Struct.new(:duration, :pitch, :accented, :articulation) do
|
17
|
+
def slurred?; false; end
|
18
|
+
def accented?; accented; end
|
19
|
+
end
|
20
|
+
|
21
|
+
class NoteSequence
|
22
|
+
def self.adjust_duration duration, articulation
|
23
|
+
x = duration
|
24
|
+
y = Math.log2(x)
|
25
|
+
|
26
|
+
case articulation
|
27
|
+
when Music::Transcription::Articulations::TENUTO
|
28
|
+
x
|
29
|
+
when Music::Transcription::Articulations::PORTATO
|
30
|
+
x / (1 + 2**(y-1))
|
31
|
+
when Music::Transcription::Articulations::STACCATO
|
32
|
+
x / (1 + 2**(y))
|
33
|
+
when Music::Transcription::Articulations::STACCATISSIMO
|
34
|
+
x / (1 + 2**(y+1))
|
35
|
+
else
|
36
|
+
x - (1/16.0)*(1/(1+2**(-y)))
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
attr_reader :start, :stop, :pitches, :attacks
|
41
|
+
def initialize start, stop, pitches, attacks
|
42
|
+
if start >= stop
|
43
|
+
raise ArgumentError, "start #{start} is not less than stop #{stop}"
|
44
|
+
end
|
45
|
+
|
46
|
+
if pitches.empty?
|
47
|
+
raise ArgumentError, "no pitches given (at least one pitch is required at start offset)"
|
48
|
+
end
|
49
|
+
|
50
|
+
unless pitches.has_key?(start)
|
51
|
+
raise ArgumentError, "no start pitch given"
|
52
|
+
end
|
53
|
+
|
54
|
+
pitches.keys.each do |offset|
|
55
|
+
unless offset.between?(start,stop)
|
56
|
+
raise ArgumentError, "pitch offset #{offset} is not between start #{start} and stop #{stop}"
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
if attacks.empty?
|
61
|
+
raise ArgumentError, "no attacks given (at least one is required at start offset)"
|
62
|
+
end
|
63
|
+
|
64
|
+
unless attacks.has_key?(start)
|
65
|
+
raise ArgumentError, "no start attack given"
|
66
|
+
end
|
67
|
+
|
68
|
+
attacks.keys.each do |offset|
|
69
|
+
unless offset.between?(start,stop)
|
70
|
+
raise ArgumentError, "attack offset #{offset} is not between start #{start} and stop #{stop}"
|
71
|
+
end
|
72
|
+
end
|
73
|
+
|
74
|
+
@start, @stop = start, stop
|
75
|
+
@pitches, @attacks = pitches, attacks
|
76
|
+
end
|
77
|
+
|
78
|
+
def self.from_elements offset, elements
|
79
|
+
pitches = {}
|
80
|
+
attacks = {}
|
81
|
+
start = offset
|
82
|
+
|
83
|
+
if elements.empty?
|
84
|
+
raise ArgumentError, "no elements given"
|
85
|
+
end
|
86
|
+
|
87
|
+
last = elements.last
|
88
|
+
skip_attack = false
|
89
|
+
elements.each do |el|
|
90
|
+
if skip_attack
|
91
|
+
unless pitches.max[1] == el.pitch
|
92
|
+
pitches[offset] = el.pitch
|
93
|
+
end
|
94
|
+
else
|
95
|
+
pitches[offset] = el.pitch
|
96
|
+
attacks[offset] = el.accented ? ACCENTED : UNACCENTED
|
97
|
+
end
|
98
|
+
skip_attack = el.slurred?
|
99
|
+
|
100
|
+
unless el.equal?(last)
|
101
|
+
offset += el.duration
|
102
|
+
end
|
103
|
+
end
|
104
|
+
stop = offset + NoteSequence.adjust_duration(last.duration, last.articulation)
|
105
|
+
|
106
|
+
new(start, stop, pitches, attacks)
|
107
|
+
end
|
108
|
+
|
109
|
+
def duration; @stop - @start; end
|
110
|
+
end
|
111
|
+
|
112
|
+
end
|
113
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
module Interpolation
|
5
|
+
# Linear interpolation
|
6
|
+
# Given 2 sample points, interpolates a value anywhere between the two points.
|
7
|
+
#
|
8
|
+
# @param [Numeric] y0 First (left) y-value
|
9
|
+
# @param [Numeric] y1 Second (right) y-value
|
10
|
+
# @param [Numeric] x Percent distance (along the x-axis) between the two y-values
|
11
|
+
def self.linear y0, y1, x
|
12
|
+
raise ArgumentError, "x is not between 0.0 and 1.0" unless x.between?(0.0,1.0)
|
13
|
+
return y0 + x * (y1 - y0)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
class NoteLinker
|
5
|
+
def self.find_unlinked_pitches note
|
6
|
+
linked = Set.new(note.pitches) & note.links.keys
|
7
|
+
(Set.new(note.pitches) - linked).to_a
|
8
|
+
end
|
9
|
+
|
10
|
+
def self.find_untargeted_pitches note, next_note
|
11
|
+
linked = Set.new(note.pitches) & note.links.keys
|
12
|
+
targeted = Set.new(linked.map {|p| note.links[p].target_pitch })
|
13
|
+
(Set.new(next_note.pitches) - targeted).to_a
|
14
|
+
end
|
15
|
+
|
16
|
+
def self.figure_links note, next_note
|
17
|
+
unlinked = find_unlinked_pitches(note)
|
18
|
+
untargeted = find_untargeted_pitches(note, next_note)
|
19
|
+
Optimization.linking(unlinked, untargeted)
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.fully_link note, next_note, link_class
|
23
|
+
figure_links(note,next_note).each do |pitch,tgt_pitch|
|
24
|
+
note.links[pitch] = link_class.new(tgt_pitch)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,33 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
module Optimization
|
5
|
+
def self.linking unlinked, untargeted
|
6
|
+
n = [unlinked.size, untargeted.size].min
|
7
|
+
|
8
|
+
bestsol = nil
|
9
|
+
bestscore = Float::INFINITY
|
10
|
+
unlinked.combination(n).each do |comb|
|
11
|
+
untargeted.permutation(n).each do |perm|
|
12
|
+
score = 0
|
13
|
+
n.times do |i|
|
14
|
+
score += perm[i].diff(comb[i]).abs
|
15
|
+
end
|
16
|
+
|
17
|
+
if score < bestscore
|
18
|
+
bestsol = [ comb, perm ]
|
19
|
+
bestscore = score
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
solution = {}
|
25
|
+
n.times do |i|
|
26
|
+
solution[ bestsol[0][i] ] = bestsol[1][i]
|
27
|
+
end
|
28
|
+
return solution
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
# Combine functions that are each applicable for a non-overlapping domain.
|
5
|
+
#
|
6
|
+
# @author James Tunnell
|
7
|
+
class PiecewiseFunction
|
8
|
+
attr_reader :pieces
|
9
|
+
|
10
|
+
# Take an array of points (each point is a two-element array pair) and
|
11
|
+
# create a piecewise function to calculate values in-between.
|
12
|
+
def initialize points = []
|
13
|
+
@pieces = { }
|
14
|
+
|
15
|
+
points = points.sort_by {|p| p[0]}
|
16
|
+
|
17
|
+
if points.count > 1
|
18
|
+
if points.is_a?(Hash)
|
19
|
+
points = points.to_a
|
20
|
+
end
|
21
|
+
|
22
|
+
for i in 1...points.count
|
23
|
+
add_points points[i-1], points[i]
|
24
|
+
end
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
def add_points prev_point, point
|
29
|
+
domain = prev_point[0]..point[0]
|
30
|
+
func = lambda do |x|
|
31
|
+
perc = (x - domain.min).to_f / (domain.max - domain.min)
|
32
|
+
y = Interpolation.linear prev_point[1], point[1], perc
|
33
|
+
return y
|
34
|
+
end
|
35
|
+
add_piece(domain, func)
|
36
|
+
end
|
37
|
+
|
38
|
+
# Add a function piece, which covers the given domain (includes domain start
|
39
|
+
# but not the end).
|
40
|
+
# @param [Range] domain The function domain. If this overlaps an existing domain,
|
41
|
+
# the existing domain will be split with the non-
|
42
|
+
# overlapping pieces kept and the overlapping old piece
|
43
|
+
# discarded.
|
44
|
+
def add_piece domain, func
|
45
|
+
|
46
|
+
raise ArgumentError, "domain is not a Range" if !domain.is_a? Range
|
47
|
+
raise ArgumentError, "func is not a Proc" if !func.is_a? Proc
|
48
|
+
|
49
|
+
contains_domain_completely = @pieces.select { |d,f| d.include?(domain.begin) && d.include?(domain.end) }
|
50
|
+
if contains_domain_completely.any?
|
51
|
+
contains_domain_completely.each do |d,f|
|
52
|
+
l = d.begin...domain.begin
|
53
|
+
if d.exclude_end?
|
54
|
+
r = domain.end...d.end
|
55
|
+
else
|
56
|
+
r = domain.end..d.end
|
57
|
+
end
|
58
|
+
|
59
|
+
@pieces.delete d
|
60
|
+
|
61
|
+
if domain.begin != d.begin
|
62
|
+
@pieces[l] = f
|
63
|
+
end
|
64
|
+
if domain.end == d.end
|
65
|
+
@pieces[domain.begin..domain.end] = func
|
66
|
+
else
|
67
|
+
@pieces[domain.begin...domain.end] = func
|
68
|
+
@pieces[r] = f
|
69
|
+
end
|
70
|
+
end
|
71
|
+
else
|
72
|
+
delete_completely = @pieces.select { |d,f| domain.include?(d.begin) && domain.include?(d.end) }
|
73
|
+
delete_completely.each do |d,f|
|
74
|
+
@pieces.delete d
|
75
|
+
end
|
76
|
+
|
77
|
+
# should only be one
|
78
|
+
move_end = @pieces.select { |d,f| domain.include?(d.end) }
|
79
|
+
move_end.each do |d,f|
|
80
|
+
@pieces.delete d
|
81
|
+
@pieces[d.begin...domain.begin] = f
|
82
|
+
end
|
83
|
+
|
84
|
+
# should only be one
|
85
|
+
move_begin = @pieces.select { |d,f| domain.include?(d.begin) }
|
86
|
+
move_begin.each do |d,f|
|
87
|
+
@pieces.delete d
|
88
|
+
if d.exclude_end?
|
89
|
+
@pieces[domain.end...d.end] = f
|
90
|
+
else
|
91
|
+
@pieces[domain.end..d.end] = f
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
if move_begin.any?
|
96
|
+
@pieces[domain.begin...domain.end] = func
|
97
|
+
else
|
98
|
+
@pieces[domain] = func
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
|
103
|
+
# Evaluate the piecewise function by finding a function piece whose domain
|
104
|
+
# includes the given independent value.
|
105
|
+
def eval x
|
106
|
+
y = nil
|
107
|
+
|
108
|
+
@pieces.each do |domain, func|
|
109
|
+
if domain.include? x
|
110
|
+
y = func.call x
|
111
|
+
break
|
112
|
+
end
|
113
|
+
end
|
114
|
+
|
115
|
+
if y.nil?
|
116
|
+
raise ArgumentError, "The input #{x} is not in the domain."
|
117
|
+
end
|
118
|
+
|
119
|
+
return y
|
120
|
+
end
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,172 @@
|
|
1
|
+
module Music
|
2
|
+
module Performance
|
3
|
+
|
4
|
+
# Given a start value, and value changes, compute the value at any offset.
|
5
|
+
class ValueComputer
|
6
|
+
attr_reader :piecewise_function
|
7
|
+
|
8
|
+
def initialize start_value, value_changes = {}
|
9
|
+
@piecewise_function = PiecewiseFunction.new
|
10
|
+
set_default_value start_value
|
11
|
+
|
12
|
+
if value_changes.any?
|
13
|
+
value_changes.sort.each do |offset,change|
|
14
|
+
|
15
|
+
case change
|
16
|
+
when Music::Transcription::Change::Immediate
|
17
|
+
add_immediate_change change, offset
|
18
|
+
when Music::Transcription::Change::Gradual
|
19
|
+
add_linear_change change, offset
|
20
|
+
# add_sigmoid_change change, offset
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|
26
|
+
|
27
|
+
# Compute the value at the given offset.
|
28
|
+
# @param [Numeric] offset The given offset to compute value at.
|
29
|
+
def value_at offset
|
30
|
+
@piecewise_function.eval offset
|
31
|
+
end
|
32
|
+
|
33
|
+
def sample xmin, xmax, srate
|
34
|
+
sample_period = Rational(1,srate)
|
35
|
+
((xmin.to_r)..(xmax.to_r)).step(sample_period).map do |x|
|
36
|
+
value_at(x)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# finds the minimum domain value
|
41
|
+
def domain_min
|
42
|
+
-Float::INFINITY
|
43
|
+
end
|
44
|
+
|
45
|
+
# finds the maximum domain value
|
46
|
+
def domain_max
|
47
|
+
Float::INFINITY
|
48
|
+
end
|
49
|
+
|
50
|
+
# finds the minimum domain value
|
51
|
+
def self.domain_min
|
52
|
+
-Float::INFINITY
|
53
|
+
end
|
54
|
+
|
55
|
+
# finds the maximum domain value
|
56
|
+
def self.domain_max
|
57
|
+
Float::INFINITY
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def set_default_value value
|
63
|
+
func = lambda {|x| value }
|
64
|
+
@piecewise_function.add_piece( domain_min..domain_max, func )
|
65
|
+
end
|
66
|
+
|
67
|
+
# Add a function piece to the piecewise function, which will to compute value
|
68
|
+
# for a matching note offset. Transition duration will be ignored since the
|
69
|
+
# change is immediate.
|
70
|
+
#
|
71
|
+
# @param [ValueChange] value_change An event with information about the new value.
|
72
|
+
# @param [Numeric] offset
|
73
|
+
def add_immediate_change value_change, offset
|
74
|
+
func = nil
|
75
|
+
value = value_change.value
|
76
|
+
domain = offset..domain_max
|
77
|
+
func = lambda {|x| value }
|
78
|
+
|
79
|
+
@piecewise_function.add_piece domain, func
|
80
|
+
end
|
81
|
+
|
82
|
+
# Add a function piece to the piecewise function, which will to compute value
|
83
|
+
# for a matching note offset. If the dynamic event duration is non-zero, a
|
84
|
+
# linear transition function is created.
|
85
|
+
#
|
86
|
+
# @param [ValueChange] value_change An event with information about the new value.
|
87
|
+
# @param [Numeric] offset
|
88
|
+
def add_linear_change value_change, offset
|
89
|
+
|
90
|
+
func = nil
|
91
|
+
value = value_change.value
|
92
|
+
duration = value_change.duration
|
93
|
+
domain = offset..domain_max
|
94
|
+
|
95
|
+
if duration == 0
|
96
|
+
add_immediate_change(value_change, offset)
|
97
|
+
else
|
98
|
+
b = @piecewise_function.eval domain.first
|
99
|
+
m = (value.to_f - b.to_f) / duration.to_f
|
100
|
+
|
101
|
+
func = lambda do |x|
|
102
|
+
raise RangeError, "#{x} is not in the domain" if !domain.include?(x)
|
103
|
+
|
104
|
+
if x < (domain.first + duration)
|
105
|
+
(m * (x - domain.first)) + b
|
106
|
+
else
|
107
|
+
value
|
108
|
+
end
|
109
|
+
end
|
110
|
+
@piecewise_function.add_piece domain, func
|
111
|
+
end
|
112
|
+
end
|
113
|
+
|
114
|
+
# Add a function piece to the piecewise function, which will to compute value
|
115
|
+
# for a matching note offset. If the dynamic event duration is non-zero, a
|
116
|
+
# linear transition function is created.
|
117
|
+
#
|
118
|
+
# @param [ValueChange] value_change An event with information about the new value.
|
119
|
+
# @param [Numeric] offset
|
120
|
+
def add_sigmoid_change value_change, offset
|
121
|
+
|
122
|
+
func = nil
|
123
|
+
start_value = @piecewise_function.eval offset
|
124
|
+
end_value = value_change.value
|
125
|
+
value_diff = end_value - start_value
|
126
|
+
duration = value_change.duration
|
127
|
+
domain = offset.to_f..domain_max
|
128
|
+
abruptness = 0.7 # value_change.transition.abruptness.to_f
|
129
|
+
|
130
|
+
if duration == 0
|
131
|
+
add_immediate_change(value_change,offset)
|
132
|
+
else
|
133
|
+
raise ArgumentError, "abruptness is not between 0 and 1" unless abruptness.between?(0,1)
|
134
|
+
|
135
|
+
min_magn = 2
|
136
|
+
max_magn = 6
|
137
|
+
tanh_domain_magn = abruptness * (max_magn - min_magn) + min_magn
|
138
|
+
tanh_domain = -tanh_domain_magn..tanh_domain_magn
|
139
|
+
|
140
|
+
tanh_range = Math::tanh(tanh_domain.first)..Math::tanh(tanh_domain.last)
|
141
|
+
tanh_span = tanh_range.last - tanh_range.first
|
142
|
+
|
143
|
+
func = lambda do |x|
|
144
|
+
raise RangeError, "#{x} is not in the domain" if !domain.include?(x)
|
145
|
+
if x < (domain.first + duration)
|
146
|
+
start_domain = domain.first...(domain.first + duration)
|
147
|
+
x2 = transform_domains(start_domain, tanh_domain, x)
|
148
|
+
y = Math::tanh x2
|
149
|
+
z = (y / tanh_span) + 0.5 # ranges from 0 to 1
|
150
|
+
start_value + (z * value_diff)
|
151
|
+
else
|
152
|
+
end_value
|
153
|
+
end
|
154
|
+
end
|
155
|
+
@piecewise_function.add_piece domain, func
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# x should be in the start domain
|
160
|
+
def transform_domains start_domain, end_domain, x
|
161
|
+
perc = (x - start_domain.first) / (start_domain.last - start_domain.first).to_f
|
162
|
+
x2 = perc * (end_domain.last - end_domain.first) + end_domain.first
|
163
|
+
end
|
164
|
+
|
165
|
+
# 0 to 1
|
166
|
+
def logistic x
|
167
|
+
1.0 / (1 + Math::exp(-x))
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
end
|
172
|
+
end
|