beats 1.2.4 → 1.2.5

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.
@@ -0,0 +1,162 @@
1
+ module Beats
2
+ # This class is used to transform a Song object into an equivalent Song object whose
3
+ # sample data will be generated faster by the audio engine.
4
+ #
5
+ # The primary method is optimize(). Currently, it performs two optimizations:
6
+ #
7
+ # 1.) Breaks patterns into shorter patterns. Generating one long Pattern is generally
8
+ # slower than generating several short Patterns with the same combined length.
9
+ # 2.) Replaces Patterns which are equivalent (i.e. they have the same tracks with the
10
+ # same rhythms) with one canonical Pattern. This allows for better caching, by
11
+ # preventing the audio engine from generating the same sample data more than once.
12
+ #
13
+ # Note that step #1 actually performs double duty, because breaking Patterns into smaller
14
+ # pieces increases the likelihood there will be duplicates that can be combined.
15
+ class SongOptimizer
16
+ def initialize
17
+ end
18
+
19
+ # Returns a Song that will produce the same output as original_song, but should be
20
+ # generated faster.
21
+ def optimize(original_song, max_pattern_length)
22
+ # 1.) Create a new song, cloned from the original
23
+ optimized_song = original_song.copy_ignoring_patterns_and_flow()
24
+
25
+ # 2.) Subdivide patterns
26
+ optimized_song = subdivide_song_patterns(original_song, optimized_song, max_pattern_length)
27
+
28
+ # 3.) Prune duplicate patterns
29
+ optimized_song = prune_duplicate_patterns(optimized_song)
30
+
31
+ optimized_song
32
+ end
33
+
34
+ protected
35
+
36
+ # Splits the patterns of a Song into smaller patterns, each one with at most
37
+ # max_pattern_length steps. For example, if max_pattern_length is 4, then
38
+ # the following pattern:
39
+ #
40
+ # track1: X...X...X.
41
+ # track2: ..X.....X.
42
+ # track3: X.X.X.X.X.
43
+ #
44
+ # will be converted into the following 3 patterns:
45
+ #
46
+ # track1: X...
47
+ # track2: ..X.
48
+ # track3: X.X.
49
+ #
50
+ # track1: X...
51
+ # track3: X.X.
52
+ #
53
+ # track1: X.
54
+ # track2: X.
55
+ # track3: X.
56
+ #
57
+ # Note that if a track in a sub-divided pattern has no triggers (such as track2 in the
58
+ # 2nd pattern above), it will not be included in the new pattern.
59
+ def subdivide_song_patterns(original_song, optimized_song, max_pattern_length)
60
+ blank_track_pattern = '.' * max_pattern_length
61
+
62
+ # For each pattern, add a new pattern to new song every max_pattern_length steps
63
+ optimized_flow = {}
64
+ original_song.patterns.values.each do |pattern|
65
+ step_index = 0
66
+ optimized_flow[pattern.name] = []
67
+
68
+ while(pattern.tracks.values.first.rhythm[step_index] != nil) do
69
+ # TODO: Is this pattern 100% sufficient to prevent collisions between subdivided
70
+ # pattern names and existing patterns with numeric suffixes?
71
+ new_pattern = optimized_song.pattern("#{pattern.name}_#{step_index}".to_sym)
72
+ optimized_flow[pattern.name] << new_pattern.name
73
+ pattern.tracks.values.each do |track|
74
+ sub_track_pattern = track.rhythm[step_index...(step_index + max_pattern_length)]
75
+
76
+ if sub_track_pattern != blank_track_pattern
77
+ new_pattern.track(track.name, sub_track_pattern)
78
+ end
79
+ end
80
+
81
+ # If no track has a trigger during this step pattern, add a blank track.
82
+ # Otherwise, this pattern will have no steps, and no sound will be generated,
83
+ # causing the pattern to be "compacted away".
84
+ if new_pattern.tracks.empty?
85
+ new_pattern.track("placeholder", blank_track_pattern)
86
+ end
87
+
88
+ step_index += max_pattern_length
89
+ end
90
+ end
91
+
92
+ # Replace the Song's flow to reference the new sub-divided patterns
93
+ # instead of the old patterns.
94
+ optimized_flow = original_song.flow.map do |original_pattern|
95
+ optimized_flow[original_pattern]
96
+ end
97
+ optimized_song.flow = optimized_flow.flatten
98
+
99
+ optimized_song
100
+ end
101
+
102
+
103
+ # Replaces any Patterns that are duplicates (i.e., each track uses the same sound and has
104
+ # the same rhythm) with a single canonical pattern.
105
+ #
106
+ # The benefit of this is that it allows more effective caching. For example, suppose Pattern A
107
+ # and Pattern B are equivalent. If Pattern A gets generated first, it will be cached. When
108
+ # Pattern B gets generated, it will be generated from scratch instead of using Pattern A's
109
+ # cached data. Consolidating duplicates into one prevents this from happening.
110
+ #
111
+ # Duplicate Patterns are more likely to occur after calling subdivide_song_patterns().
112
+ def prune_duplicate_patterns(song)
113
+ pattern_replacements = determine_pattern_replacements(song.patterns)
114
+
115
+ # Update flow to remove references to duplicates
116
+ new_flow = song.flow
117
+ pattern_replacements.each do |duplicate, replacement|
118
+ new_flow = new_flow.map do |pattern_name|
119
+ (pattern_name == duplicate) ? replacement : pattern_name
120
+ end
121
+ end
122
+ song.flow = new_flow
123
+
124
+ # This isn't strictly necessary, but makes resulting songs easier to read for debugging purposes.
125
+ song.remove_unused_patterns()
126
+
127
+ song
128
+ end
129
+
130
+
131
+ # Examines a set of patterns definitions, determining which ones have the same tracks with the same
132
+ # rhythms. Then constructs a hash of pattern => pattern indicating that all occurances in the flow
133
+ # of the key should be replaced with the value, so that the other equivalent definitions can be pruned
134
+ # from the song (and hence their sample data doesn't need to be generated).
135
+ def determine_pattern_replacements(patterns)
136
+ seen_patterns = []
137
+ replacements = {}
138
+
139
+ # Pattern names are sorted to ensure predictable pattern replacement. Makes tests easier to write.
140
+ # Sort function added manually because Ruby 1.8 doesn't know how to sort symbols...
141
+ pattern_names = patterns.keys.sort {|x, y| x.to_s <=> y.to_s }
142
+
143
+ # Detect duplicates
144
+ pattern_names.each do |pattern_name|
145
+ pattern = patterns[pattern_name]
146
+ found_duplicate = false
147
+ seen_patterns.each do |seen_pattern|
148
+ if !found_duplicate && pattern.same_tracks_as?(seen_pattern)
149
+ replacements[pattern.name.to_sym] = seen_pattern.name.to_sym
150
+ found_duplicate = true
151
+ end
152
+ end
153
+
154
+ if !found_duplicate
155
+ seen_patterns << pattern
156
+ end
157
+ end
158
+
159
+ replacements
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,217 @@
1
+ module Beats
2
+ class SongParseError < RuntimeError; end
3
+
4
+
5
+ # This class is used to parse a raw YAML song definition into domain objects (i.e.
6
+ # Song, Pattern, Track, and Kit). These domain objects can then be used by AudioEngine
7
+ # to generate the actual audio data that is saved to disk.
8
+ #
9
+ # The sole public method is parse(). It takes a raw YAML string and returns a Song and
10
+ # Kit object (or raises an error if the YAML string couldn't be parsed correctly).
11
+ class SongParser
12
+ DONT_USE_STRUCTURE_WARNING =
13
+ "\n" +
14
+ "WARNING! This song contains a 'Structure' section in the header.\n" +
15
+ "As of BEATS 1.2.1, the 'Structure' section should be renamed 'Flow'.\n" +
16
+ "You should change your song file, in a future version using 'Structure' will cause an error.\n"
17
+
18
+ NO_SONG_HEADER_ERROR_MSG =
19
+ "Song must have a header. Here's an example:
20
+
21
+ Song:
22
+ Tempo: 120
23
+ Flow:
24
+ - Verse: x2
25
+ - Chorus: x2"
26
+
27
+ def initialize
28
+ end
29
+
30
+
31
+ # Parses a raw YAML song definition and converts it into a Song and Kit object.
32
+ def parse(base_path, raw_yaml_string)
33
+ raw_song_components = hashify_raw_yaml(raw_yaml_string)
34
+
35
+ unless raw_song_components[:folder].nil?
36
+ base_path = raw_song_components[:folder]
37
+ end
38
+
39
+ song = Song.new()
40
+
41
+ # 1.) Set tempo
42
+ begin
43
+ unless raw_song_components[:tempo].nil?
44
+ song.tempo = raw_song_components[:tempo]
45
+ end
46
+ rescue InvalidTempoError => detail
47
+ raise SongParseError, "#{detail}"
48
+ end
49
+
50
+ # 2.) Build the kit
51
+ begin
52
+ kit = build_kit(base_path, raw_song_components[:kit], raw_song_components[:patterns])
53
+ rescue SoundFileNotFoundError => detail
54
+ raise SongParseError, "#{detail}"
55
+ rescue InvalidSoundFormatError => detail
56
+ raise SongParseError, "#{detail}"
57
+ end
58
+
59
+ # 3.) Load patterns
60
+ add_patterns_to_song(song, raw_song_components[:patterns])
61
+
62
+ # 4.) Set flow
63
+ if raw_song_components[:flow].nil?
64
+ raise SongParseError, "Song must have a Flow section in the header."
65
+ else
66
+ set_song_flow(song, raw_song_components[:flow])
67
+ end
68
+
69
+ return song, kit
70
+ end
71
+
72
+
73
+ private
74
+
75
+
76
+ def hashify_raw_yaml(raw_yaml_string)
77
+ begin
78
+ raw_song_definition = YAML.load(raw_yaml_string)
79
+ rescue ArgumentError => detail
80
+ raise SongParseError, "Syntax error in YAML file"
81
+ end
82
+
83
+ raw_song_components = {}
84
+ raw_song_components[:full_definition] = downcase_hash_keys(raw_song_definition)
85
+
86
+ unless raw_song_components[:full_definition]["song"].nil?
87
+ raw_song_components[:header] = downcase_hash_keys(raw_song_components[:full_definition]["song"])
88
+ else
89
+ raise SongParseError, NO_SONG_HEADER_ERROR_MSG
90
+ end
91
+ raw_song_components[:tempo] = raw_song_components[:header]["tempo"]
92
+ raw_song_components[:folder] = raw_song_components[:header]["folder"]
93
+ raw_song_components[:kit] = raw_song_components[:header]["kit"]
94
+
95
+ raw_flow = raw_song_components[:header]["flow"]
96
+ raw_structure = raw_song_components[:header]["structure"]
97
+ unless raw_flow.nil?
98
+ raw_song_components[:flow] = raw_flow
99
+ else
100
+ unless raw_structure.nil?
101
+ puts DONT_USE_STRUCTURE_WARNING
102
+ end
103
+
104
+ raw_song_components[:flow] = raw_structure
105
+ end
106
+
107
+ raw_song_components[:patterns] = raw_song_components[:full_definition].reject {|k, v| k == "song"}
108
+
109
+ return raw_song_components
110
+ end
111
+
112
+
113
+ def build_kit(base_path, raw_kit, raw_patterns)
114
+ kit_items = {}
115
+
116
+ # Add sounds defined in the Kit section of the song header
117
+ # Converts [{a=>1}, {b=>2}, {c=>3}] from raw YAML to {a=>1, b=>2, c=>3}
118
+ # TODO: Raise error is same name is defined more than once in the Kit
119
+ unless raw_kit.nil?
120
+ raw_kit.each do |kit_item|
121
+ kit_items[kit_item.keys.first] = kit_item.values.first
122
+ end
123
+ end
124
+
125
+ # Add sounds not defined in Kit section, but used in individual tracks
126
+ # TODO Investigate detecting duplicate keys already defined in the Kit section, as this could possibly
127
+ # result in a performance improvement when the sound has to be converted to a different bit rate/num channels,
128
+ # as well as use less memory.
129
+ raw_patterns.keys.each do |key|
130
+ track_list = raw_patterns[key]
131
+
132
+ unless track_list.nil?
133
+ track_list.each do |track_definition|
134
+ track_name = track_definition.keys.first
135
+ track_path = track_name
136
+
137
+ if track_name != Pattern::FLOW_TRACK_NAME && kit_items[track_name].nil?
138
+ kit_items[track_name] = track_path
139
+ end
140
+ end
141
+ end
142
+ end
143
+
144
+ Kit.new(base_path, kit_items)
145
+ end
146
+
147
+
148
+ def add_patterns_to_song(song, raw_patterns)
149
+ raw_patterns.keys.each do |key|
150
+ new_pattern = song.pattern key.to_sym
151
+
152
+ track_list = raw_patterns[key]
153
+ # TODO Also raise error if only there is only 1 track and it's a flow track
154
+ if track_list.nil?
155
+ # TODO: Use correct capitalization of pattern name in error message
156
+ # TODO: Possibly allow if pattern not referenced in the Flow, or has 0 repeats?
157
+ raise SongParseError, "Pattern '#{key}' has no tracks. It needs at least one."
158
+ end
159
+
160
+ # TODO: What if there is more than one flow? Raise error, or have last one win?
161
+ track_list.each do |track_definition|
162
+ track_name = track_definition.keys.first
163
+
164
+ # Handle case where no track rhythm is specified (i.e. "- foo.wav:" instead of "- foo.wav: X.X.X.X.")
165
+ track_definition[track_name] ||= ""
166
+
167
+ new_pattern.track track_name, track_definition[track_name]
168
+ end
169
+ end
170
+ end
171
+
172
+
173
+ def set_song_flow(song, raw_flow)
174
+ flow = []
175
+
176
+ raw_flow.each{|pattern_item|
177
+ if pattern_item.class == String
178
+ pattern_item = {pattern_item => "x1"}
179
+ end
180
+
181
+ pattern_name = pattern_item.keys.first
182
+ pattern_name_sym = pattern_name.downcase.to_sym
183
+
184
+ # Convert the number of repeats from a String such as "x4" into an integer such as 4.
185
+ multiples_str = pattern_item[pattern_name]
186
+ multiples_str.slice!(0)
187
+ multiples = multiples_str.to_i
188
+
189
+ unless multiples_str.match(/[^0-9]/).nil?
190
+ raise SongParseError,
191
+ "'#{multiples_str}' is an invalid number of repeats for pattern '#{pattern_name}'. Number of repeats should be a whole number."
192
+ else
193
+ if multiples < 0
194
+ raise SongParseError, "'#{multiples_str}' is an invalid number of repeats for pattern '#{pattern_name}'. Must be 0 or greater."
195
+ elsif multiples > 0 && !song.patterns.has_key?(pattern_name_sym)
196
+ # This test is purposefully designed to only throw an error if the number of repeats is greater
197
+ # than 0. This allows you to specify an undefined pattern in the flow with "x0" repeats.
198
+ # This can be convenient for defining the flow before all patterns have been added to the song file.
199
+ raise SongParseError, "Song flow includes non-existent pattern: #{pattern_name}."
200
+ end
201
+ end
202
+
203
+ multiples.times { flow << pattern_name_sym }
204
+ }
205
+ song.flow = flow
206
+ end
207
+
208
+
209
+ # Converts all hash keys to be lowercase
210
+ def downcase_hash_keys(hash)
211
+ return hash.inject({}) do |new_hash, pair|
212
+ new_hash[pair.first.downcase] = pair.last
213
+ new_hash
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,57 @@
1
+ module Beats
2
+ class InvalidRhythmError < RuntimeError; end
3
+
4
+
5
+ # Domain object which models a kit sound playing a rhythm. For example,
6
+ # a bass drum playing every quarter note for two measures.
7
+ #
8
+ # This object is like sheet music; the AudioEngine is responsible creating actual
9
+ # audio data for a Track (with the help of a Kit).
10
+ class Track
11
+ REST = "."
12
+ BEAT = "X"
13
+ BARLINE = "|"
14
+
15
+ def initialize(name, rhythm)
16
+ # TODO: Add validation for input parameters
17
+ @name = name
18
+ self.rhythm = rhythm
19
+ end
20
+
21
+ # TODO: What to have this invoked when setting like this?
22
+ # track.rhythm[x..y] = whatever
23
+ def rhythm=(rhythm)
24
+ @rhythm = rhythm.delete(BARLINE)
25
+ beats = []
26
+
27
+ beat_length = 0
28
+ #rhythm.each_char{|ch|
29
+ @rhythm.each_byte do |ch|
30
+ ch = ch.chr
31
+ if ch == BEAT
32
+ beats << beat_length
33
+ beat_length = 1
34
+ elsif ch == REST
35
+ beat_length += 1
36
+ else
37
+ raise InvalidRhythmError, "Track #{@name} has an invalid rhythm: '#{rhythm}'. Can only contain '#{BEAT}', '#{REST}' or '#{BARLINE}'"
38
+ end
39
+ end
40
+
41
+ if beat_length > 0
42
+ beats << beat_length
43
+ end
44
+ if beats == []
45
+ beats = [0]
46
+ end
47
+ @beats = beats
48
+ end
49
+
50
+ def step_count
51
+ @rhythm.length
52
+ end
53
+
54
+ attr_accessor :name
55
+ attr_reader :rhythm, :beats
56
+ end
57
+ end
@@ -30,7 +30,7 @@ module WaveFile
30
30
  end
31
31
 
32
32
  @file.syswrite(packed_buffer_data[:data])
33
- @samples_written += packed_buffer_data[:sample_count]
33
+ @total_sample_frames += packed_buffer_data[:sample_count]
34
34
  end
35
35
  end
36
36
  end
@@ -28,15 +28,15 @@ class AudioEngineTest < Test::Unit::TestCase
28
28
  test_engines = {}
29
29
  base_path = File.dirname(__FILE__) + "/.."
30
30
  song_parser = SongParser.new()
31
-
31
+
32
32
  test_engines[:blank] = AudioEngine.new(Song.new(), Kit.new(base_path, {}))
33
33
 
34
34
  FIXTURES.each do |fixture_name|
35
35
  song, kit = song_parser.parse(base_path, File.read("test/fixtures/valid/#{fixture_name}.txt"))
36
36
  test_engines[fixture_name] = AudioEngine.new(song, kit)
37
37
  end
38
-
39
- return test_engines
38
+
39
+ test_engines
40
40
  end
41
41
 
42
42
  def test_initialize
@@ -142,7 +142,7 @@ class AudioEngineTest < Test::Unit::TestCase
142
142
  #helper_generate_track_sample_data kit, "XX", 1, [-100, -100], [200, 300, -400]
143
143
 
144
144
  # 3C.) Tick sample length is shorter than the sound sample data, but not by an integer amount.
145
- #
145
+ #
146
146
  # Each step of 1.83 samples should end on the following boundaries:
147
147
  # Tick: 1, 2, 3, 4, 5, 6
148
148
  # Raw: 0.0, 1.83, 3.66, 5.49, 7.32, 9.15, 10.98
@@ -162,7 +162,7 @@ class AudioEngineTest < Test::Unit::TestCase
162
162
  engine = MockAudioEngine.new(Song.new(), kit)
163
163
  engine.step_sample_length = step_sample_length
164
164
  actual = engine.generate_track_sample_data(track, kit.get_sample_data("S"))
165
-
165
+
166
166
  assert_equal(Hash, actual.class)
167
167
  assert_equal(["overflow", "primary"], actual.keys.map{|key| key.to_s}.sort)
168
168
  assert_equal(expected_primary, actual[:primary])