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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/.document +3 -0
  3. data/.gitignore +7 -0
  4. data/.rspec +1 -0
  5. data/.ruby-version +1 -0
  6. data/.yardopts +1 -0
  7. data/ChangeLog.rdoc +5 -0
  8. data/Gemfile +3 -0
  9. data/LICENSE.txt +20 -0
  10. data/README.rdoc +28 -0
  11. data/Rakefile +54 -0
  12. data/bin/midify +61 -0
  13. data/lib/music-performance.rb +25 -0
  14. data/lib/music-performance/arrangement/midi/midi_events.rb +9 -0
  15. data/lib/music-performance/arrangement/midi/midi_util.rb +38 -0
  16. data/lib/music-performance/arrangement/midi/part_sequencer.rb +121 -0
  17. data/lib/music-performance/arrangement/midi/score_sequencer.rb +33 -0
  18. data/lib/music-performance/conversion/glissando_converter.rb +36 -0
  19. data/lib/music-performance/conversion/note_sequence_extractor.rb +100 -0
  20. data/lib/music-performance/conversion/note_time_converter.rb +76 -0
  21. data/lib/music-performance/conversion/portamento_converter.rb +26 -0
  22. data/lib/music-performance/conversion/score_collator.rb +121 -0
  23. data/lib/music-performance/conversion/score_time_converter.rb +112 -0
  24. data/lib/music-performance/model/note_attacks.rb +21 -0
  25. data/lib/music-performance/model/note_sequence.rb +113 -0
  26. data/lib/music-performance/util/interpolation.rb +18 -0
  27. data/lib/music-performance/util/note_linker.rb +30 -0
  28. data/lib/music-performance/util/optimization.rb +33 -0
  29. data/lib/music-performance/util/piecewise_function.rb +124 -0
  30. data/lib/music-performance/util/value_computer.rb +172 -0
  31. data/lib/music-performance/version.rb +7 -0
  32. data/music-performance.gemspec +33 -0
  33. data/spec/conversion/glissando_converter_spec.rb +93 -0
  34. data/spec/conversion/note_sequence_extractor_spec.rb +230 -0
  35. data/spec/conversion/note_time_converter_spec.rb +96 -0
  36. data/spec/conversion/portamento_converter_spec.rb +91 -0
  37. data/spec/conversion/score_collator_spec.rb +136 -0
  38. data/spec/conversion/score_time_converter_spec.rb +73 -0
  39. data/spec/model/note_sequence_spec.rb +147 -0
  40. data/spec/music-performance_spec.rb +7 -0
  41. data/spec/spec_helper.rb +8 -0
  42. data/spec/util/note_linker_spec.rb +68 -0
  43. data/spec/util/optimization_spec.rb +73 -0
  44. data/spec/util/value_computer_spec.rb +146 -0
  45. metadata +242 -0
@@ -0,0 +1,7 @@
1
+ # Prepare transcribed score for computer performance
2
+ module Music
3
+ module Performance
4
+ # music-performance version
5
+ VERSION = "0.2.1"
6
+ end
7
+ end
@@ -0,0 +1,33 @@
1
+ # -*- encoding: utf-8 -*-
2
+
3
+ require File.expand_path('../lib/music-performance/version', __FILE__)
4
+
5
+ Gem::Specification.new do |gem|
6
+ gem.name = "music-performance"
7
+ gem.version = Music::Performance::VERSION
8
+ gem.summary = %q{Classes for representing music notational features like pitch, note, loudness, tempo, etc.}
9
+ gem.description = <<DESCRIPTION
10
+ Prepare a transcribed musical score for performance by a computer.
11
+ DESCRIPTION
12
+ gem.license = "MIT"
13
+ gem.authors = ["James Tunnell"]
14
+ gem.email = "jamestunnell@gmail.com"
15
+ gem.homepage = "https://github.com/jamestunnell/music-performance"
16
+
17
+ gem.files = `git ls-files`.split($/)
18
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
19
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
20
+ gem.require_paths = ['lib']
21
+
22
+ gem.add_development_dependency 'bundler', '~> 1.5'
23
+ gem.add_development_dependency 'rubygems-bundler', '~> 1.4'
24
+ gem.add_development_dependency 'rake', '~> 10.1'
25
+ gem.add_development_dependency 'rspec', '~> 2.14'
26
+ gem.add_development_dependency 'yard', '~> 0.8'
27
+ gem.add_development_dependency 'pry'
28
+ gem.add_development_dependency 'pry-nav'
29
+
30
+ gem.add_dependency 'music-transcription', '~> 0.17.0'
31
+ gem.add_dependency 'midilib', '~> 2.0'
32
+ gem.add_dependency 'docopt'
33
+ end
@@ -0,0 +1,93 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe GlissandoConverter do
4
+ describe '.glissando_pitches' do
5
+ context 'start pitch <= target pitch' do
6
+ [
7
+ [F3,B3],
8
+ [C4,Gb5],
9
+ [D2,C5],
10
+ [F2,F2],
11
+ [C4.transpose(0.5),D4],
12
+ [C4.transpose(0.5),D4.transpose(0.5)],
13
+ [C4.transpose(0.01),F5],
14
+ [C4.transpose(-0.01),F5.transpose(-0.01)],
15
+ ].each do |start,finish|
16
+ context "start at #{start.to_s}, target #{finish.to_s}" do
17
+ pitches = GlissandoConverter.glissando_pitches(start,finish)
18
+
19
+ it 'should begin with start pitch' do
20
+ pitches.first.should eq(start)
21
+ end
22
+
23
+ it 'should move up to next whole (zero-cent) pitches' do
24
+ (1...pitches.size).each do |i|
25
+ pitches[i].cent.should eq(0)
26
+ pitches[i].diff(pitches[i-1]).should be <= 1
27
+ end
28
+ end
29
+
30
+ it 'should end on the whole (zero-cent) pitch below target pitch' do
31
+ pitches.last.cent.should eq(0)
32
+ diff = finish.total_cents - pitches.last.total_cents
33
+ diff.should be <= 100
34
+ end
35
+ end
36
+ end
37
+ end
38
+
39
+ context 'start pitch > target pitch' do
40
+ [
41
+ [B3,F3],
42
+ [Gb5,C4],
43
+ [C5,D2],
44
+ [D4,C4.transpose(0.5)],
45
+ [D4.transpose(0.5),C4.transpose(0.5)],
46
+ [F5,C4.transpose(0.01)],
47
+ [F5.transpose(-0.01),C4.transpose(-0.01)],
48
+ ].each do |start,finish|
49
+ context "start at #{start.to_s}, target #{finish.to_s}" do
50
+ pitches = GlissandoConverter.glissando_pitches(start,finish)
51
+
52
+ it 'should move down to next whole (zero-cent) pitches' do
53
+ (1...pitches.size).each do |i|
54
+ pitches[i].cent.should eq(0)
55
+ pitches[i-1].diff(pitches[i]).should be <= 1
56
+ end
57
+ end
58
+
59
+ it 'should end on the whole (zero-cent) pitch above target pitch' do
60
+ pitches.last.cent.should eq(0)
61
+ diff = pitches.last.total_cents - finish.total_cents
62
+ diff.should be <= 100
63
+ end
64
+
65
+ end
66
+ end
67
+ end
68
+ end
69
+
70
+ describe '.glissando_elements' do
71
+ before :all do
72
+ @dur = Rational(3,2)
73
+ @acc = false
74
+ @els = GlissandoConverter.glissando_elements(C4,A4,@dur,@acc)
75
+ end
76
+
77
+ it 'should return an array of LegatoElement objects' do
78
+ @els.each {|el| el.should be_a LegatoElement }
79
+ end
80
+
81
+ it 'should split up duration among elements' do
82
+ sum = @els.map {|el| el.duration }.inject(0,:+)
83
+ sum.should eq(@dur)
84
+ end
85
+
86
+ it 'should set accented as given' do
87
+ els = GlissandoConverter.glissando_elements(C4,A4,1,false)
88
+ els.each {|el| el.accented.should eq(false) }
89
+ els = GlissandoConverter.glissando_elements(C4,A4,1,true)
90
+ els.each {|el| el.accented.should eq(true) }
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,230 @@
1
+
2
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
3
+
4
+ describe NoteSequenceExtractor do
5
+ describe '#initialize' do
6
+ it 'should clone original notes' do
7
+ notes = [ Note.quarter([C2]), Note.half, Note.half ]
8
+ extr = NoteSequenceExtractor.new(notes)
9
+ extr.notes[0].should eq(notes[0])
10
+ notes[0].transpose!(1)
11
+ extr.notes[0].should_not eq(notes[0])
12
+ end
13
+
14
+ it 'should maintain the same number of notes' do
15
+ extr = NoteSequenceExtractor.new(
16
+ [ Note.quarter, Note.half, Note.half ])
17
+ extr.notes.size.should eq 3
18
+ end
19
+
20
+ it 'should remove any bad ties (tying pitch does not exist in next note' do
21
+ extr = NoteSequenceExtractor.new(
22
+ [ Note.quarter([C4,E4], links: {C4 => Link::Tie.new}),
23
+ Note.quarter([E4]) ]
24
+ )
25
+ extr.notes[0].links.should_not have_key(C4)
26
+ end
27
+
28
+ it 'should replace any good ties with slurs' do
29
+ extr = NoteSequenceExtractor.new(
30
+ [ Note.quarter([C4,E4], links: {C4 => Link::Tie.new, E4 => Link::Tie.new}),
31
+ Note.quarter([C4,E4]) ]
32
+ )
33
+ extr.notes[0].links[C4].should be_a Link::Slur
34
+ extr.notes[0].links[E4].should be_a Link::Slur
35
+ end
36
+
37
+ it 'should remove dead slur/legato (where target pitch is non-existent)' do
38
+ extr = NoteSequenceExtractor.new(
39
+ [ Note.quarter([C4,E4], links: { C4 => Link::Slur.new(D4), E4 => Link::Legato.new(F4) }),
40
+ Note.quarter([C4]) ]
41
+ )
42
+ extr.notes[0].links.should be_empty
43
+ end
44
+
45
+ it 'should remove any link where the source pitch is missing' do
46
+ extr = NoteSequenceExtractor.new(
47
+ [ Note.quarter([C4,D4,E4,F4,G4], links: {
48
+ Bb4 => Link::Tie.new, Db4 => Link::Slur.new(C4),
49
+ Eb4 => Link::Legato.new(D4), Gb4 => Link::Glissando.new(E4),
50
+ Ab5 => Link::Portamento.new(F4)
51
+ }),
52
+ Note.quarter([C4,D4,E4,F4,G4])
53
+ ])
54
+ extr.notes[0].links.should be_empty
55
+ end
56
+
57
+ it 'should not remove portamento and glissando with non-existent target pitches' do
58
+ extr = NoteSequenceExtractor.new(
59
+ [ Note.quarter([C4,D4]),
60
+ Note.quarter([C4,D4,E4,F4,G4], links: {
61
+ C4 => Link::Tie.new, D4 => Link::Slur.new(Eb4),
62
+ E4 => Link::Legato.new(Gb4), F4 => Link::Glissando.new(A5),
63
+ G4 => Link::Portamento.new(Bb5)}) ]
64
+ )
65
+ extr.notes[-1].links.size.should eq 2
66
+ extr.notes[-1].links.should have_key(F4)
67
+ extr.notes[-1].links.should have_key(G4)
68
+ end
69
+ end
70
+
71
+ describe '#extract_sequences' do
72
+ context 'empty note array' do
73
+ it 'should return empty' do
74
+ seqs = NoteSequenceExtractor.new([]).extract_sequences
75
+ seqs.should be_empty
76
+ end
77
+ end
78
+
79
+ context 'array of only rest notes' do
80
+ it 'should return empty' do
81
+ notes = [ Note::quarter, Note::quarter ]
82
+ seqs = NoteSequenceExtractor.new(notes).extract_sequences
83
+ seqs.should be_empty
84
+ end
85
+ end
86
+
87
+ context 'array with only one note, single pitch' do
88
+ before :all do
89
+ @note = Note::quarter([C5])
90
+ @seqs = NoteSequenceExtractor.new([@note]).extract_sequences
91
+ end
92
+
93
+ it 'should return array with one sequence' do
94
+ @seqs.size.should eq 1
95
+ end
96
+
97
+ it 'should start offset 0' do
98
+ @seqs[0].start.should eq 0
99
+ end
100
+
101
+ it 'should stop offset <= note duration' do
102
+ @seqs[0].stop.should be <= @note.duration
103
+ end
104
+ end
105
+
106
+ context 'array with two slurred notes, single pitch' do
107
+ before :all do
108
+ @notes = [ Note.quarter([C5], articulation: SLUR), Note.quarter([D5]) ]
109
+ @seqs = NoteSequenceExtractor.new(@notes).extract_sequences
110
+ end
111
+
112
+ it 'should return array with one sequence' do
113
+ @seqs.size.should eq 1
114
+ end
115
+
116
+ it 'should start offset 0' do
117
+ @seqs[0].start.should eq 0
118
+ end
119
+
120
+ it 'should stop offset <= combined duration of the two notes' do
121
+ @seqs[0].stop.should be <= (@notes[0].duration + @notes[1].duration)
122
+ end
123
+ end
124
+
125
+ context 'array with one note, multiple pitches' do
126
+ before :all do
127
+ @note = Note.quarter([C5,D5,E5])
128
+ @seqs = NoteSequenceExtractor.new([@note]).extract_sequences
129
+ end
130
+
131
+ it 'should return array with as many sequences as pitches' do
132
+ @seqs.size.should eq @note.pitches.size
133
+ end
134
+
135
+ it 'should start the sequences at 0' do
136
+ @seqs.each {|s| s.start.should eq(0) }
137
+ end
138
+
139
+ it 'should end each sequence at or before note duration' do
140
+ @seqs.each {|s| s.stop.should be <= @note.duration }
141
+ end
142
+
143
+ it 'should put one pitch in each seq' do
144
+ @seqs.each {|s| s.pitches.size.should eq(1) }
145
+ end
146
+
147
+ it 'should assign a different pitch to each' do
148
+ @seqs.map {|seq| seq.pitches[0] }.sort.should eq @note.pitches.sort
149
+ end
150
+ end
151
+
152
+ context 'array with multiple notes and links' do
153
+ before :all do
154
+ @notes = [ Note.quarter([C3,E3], links: {
155
+ C3 => Link::Slur.new(D3), E3 => Link::Legato.new(F3)}),
156
+ Note.eighth([D3,F3]) ]
157
+ @seqs = NoteSequenceExtractor.new(@notes).extract_sequences
158
+ end
159
+
160
+ it 'should create a sequence for linked notes' do
161
+ @seqs.size.should eq(2)
162
+ end
163
+
164
+ it 'should add pitch at 0 from first note' do
165
+ @seqs[0].pitches.should have_key(0)
166
+ @notes[0].pitches.should include(@seqs[0].pitches[0])
167
+ @seqs[1].pitches.should have_key(0)
168
+ @notes[0].pitches.should include(@seqs[1].pitches[0])
169
+ end
170
+ end
171
+
172
+ context 'single note with single pitch, and glissando up' do
173
+ before :all do
174
+ @note = Note.whole([D3], links: { D3 => Link::Glissando.new(G3) })
175
+ @seqs = NoteSequenceExtractor.new([@note]).extract_sequences
176
+ end
177
+
178
+ it 'should produce one sequence' do
179
+ @seqs.size.should eq(1)
180
+ end
181
+
182
+ it 'should include pitches up to (not including) target pitch' do
183
+ @seqs[0].pitches.values.should include(D3,Eb3,E3,F3,Gb3)
184
+ end
185
+
186
+ it 'should produce sequence with duration <= note duration' do
187
+ @seqs[0].duration.should be <= @note.duration
188
+ end
189
+ end
190
+
191
+ context 'single note with single pitch, and glissando down' do
192
+ before :all do
193
+ @note = Note.whole([D3], links: { D3 => Link::Glissando.new(A2) })
194
+ @seqs = NoteSequenceExtractor.new([@note]).extract_sequences
195
+ end
196
+
197
+ it 'should produce one sequence' do
198
+ @seqs.size.should eq(1)
199
+ end
200
+
201
+ it 'should include pitches down to (not including) target pitch' do
202
+ @seqs[0].pitches.values.should include(D3,Db3,C3,B2,Bb2)
203
+ end
204
+
205
+ it 'should produce sequence with duration <= note duration' do
206
+ @seqs[0].duration.should be <= @note.duration
207
+ end
208
+ end
209
+
210
+ context 'two notes with single pitch, glissando up to pitch in second note' do
211
+ before :all do
212
+ @notes = [Note.whole([D3], links: { D3 => Link::Glissando.new(G3) }),
213
+ Note.quarter([G3]) ]
214
+ @seqs = NoteSequenceExtractor.new(@notes).extract_sequences
215
+ end
216
+
217
+ it 'should produce a single sequence' do
218
+ @seqs.size.should eq(1)
219
+ end
220
+
221
+ it 'should includes pitches up through target pitch' do
222
+ @seqs[0].pitches.values.should include(D3,Eb3,E3,F3,Gb3,G3)
223
+ end
224
+
225
+ it 'should produce sequence with duration <= note1dur + note2dur' do
226
+ @seqs[0].duration.should be <= (@notes[0].duration + @notes[1].duration)
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,96 @@
1
+ require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
2
+
3
+ describe NoteTimeConverter do
4
+ describe '.notes_per_second' do
5
+ it 'should multiply tempo and beat duration, then divide by 60' do
6
+ nps = NoteTimeConverter.notes_per_second(120,"1/4".to_r)
7
+ nps.should eq("1/2".to_r)
8
+ end
9
+ end
10
+
11
+ describe '#notes_per_second_at' do
12
+ it 'should invoke .notes_per_second using current tempo and beat duration values' do
13
+ tc = ValueComputer.new(
14
+ 120, 1 => Change::Gradual.new(100,1), 2 => Change::Gradual.new(150,1))
15
+ bdc = ValueComputer.new Rational(1,4)
16
+ converter = NoteTimeConverter.new(tc,bdc,200)
17
+ (0..2).step(0.2).each do |offset|
18
+ tempo = tc.value_at(offset)
19
+ beat_duration = bdc.value_at(offset)
20
+ nps = NoteTimeConverter.notes_per_second(tempo,beat_duration)
21
+ converter.notes_per_second_at(offset).should eq nps
22
+ end
23
+ end
24
+ end
25
+
26
+ describe "#time_elapsed" do
27
+ context "constant tempo" do
28
+ before :each do
29
+ @tempo_computer = ValueComputer.new 120
30
+ @beat_duration_computer = ValueComputer.new Rational(1,4)
31
+ sample_rate = 48
32
+ @converter = NoteTimeConverter.new(
33
+ @tempo_computer, @beat_duration_computer, sample_rate)
34
+ end
35
+
36
+ it "should return a time of zero when note end is zero." do
37
+ @converter.time_elapsed(0, 0).should eq(0)
38
+ end
39
+
40
+ it "should return a time of 1 second when note end is equal to the initial notes-per-second" do
41
+ note_end = @converter.notes_per_second_at(0)
42
+ @converter.time_elapsed(0, note_end).should eq(1)
43
+ end
44
+ end
45
+
46
+ context "linear tempo-change" do
47
+ before :each do
48
+ @tempo_computer = ValueComputer.new(
49
+ 120, 1 => Change::Gradual.new(60, 1))
50
+ @beat_duration_computer = ValueComputer.new(Rational(1,4))
51
+ sample_rate = 200
52
+ @converter = NoteTimeConverter.new(
53
+ @tempo_computer, @beat_duration_computer, sample_rate)
54
+ end
55
+
56
+ it "should return a time of zero when note end is zero." do
57
+ @converter.time_elapsed(0.0, 0.0).should eq(0.0)
58
+ end
59
+
60
+ it "should return a time of 3 sec during a 1-note long transition from 120bpm to 60bpm" do
61
+ @converter.notes_per_second_at(1.0).should eq(0.5)
62
+ @converter.notes_per_second_at(2.0).should eq(0.25)
63
+
64
+ @converter.time_elapsed(1.0, 2.0).should be_within(0.05).of(2.77)
65
+ end
66
+
67
+ end
68
+ end
69
+
70
+ describe "#map_note_offsets_to_time_offsets" do
71
+ context "constant tempo" do
72
+ before :each do
73
+ @tempo_computer = ValueComputer.new 120
74
+ @beat_duration_computer = ValueComputer.new Rational(1,4)
75
+ sample_rate = 4800
76
+ @converter = NoteTimeConverter.new(
77
+ @tempo_computer, @beat_duration_computer, sample_rate)
78
+ end
79
+
80
+ it "should map offset 0.0 to time 0.0" do
81
+ map = @converter.map_note_offsets_to_time_offsets [0.0]
82
+ map[0.0].should eq(0.0)
83
+ end
84
+
85
+ it "should map offset 0.25 to time 0.5" do
86
+ map = @converter.map_note_offsets_to_time_offsets [0.0, 0.25]
87
+ map[0.25].should be_within(0.01).of(0.5)
88
+ end
89
+
90
+ it "should map offset 1.0 to time 2.0" do
91
+ map = @converter.map_note_offsets_to_time_offsets [0.0, 1.0]
92
+ map[1.0].should be_within(0.01).of(2.0)
93
+ end
94
+ end
95
+ end
96
+ end