beats 1.3.0 → 2.0.0

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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.markdown +22 -42
  4. data/bin/beats +6 -7
  5. data/lib/beats.rb +2 -1
  6. data/lib/beats/audioengine.rb +13 -13
  7. data/lib/beats/beatsrunner.rb +7 -8
  8. data/lib/beats/kit.rb +12 -156
  9. data/lib/beats/kit_builder.rb +74 -0
  10. data/lib/beats/pattern.rb +2 -22
  11. data/lib/beats/song.rb +5 -55
  12. data/lib/beats/songoptimizer.rb +3 -3
  13. data/lib/beats/songparser.rb +25 -46
  14. data/lib/beats/track.rb +20 -31
  15. data/lib/beats/transforms/song_swinger.rb +2 -4
  16. data/lib/wavefile/cachingwriter.rb +2 -2
  17. data/test/audioengine_test.rb +22 -24
  18. data/test/audioutils_test.rb +1 -1
  19. data/test/cachingwriter_test.rb +13 -12
  20. data/test/fixtures/invalid/leading_bar_line.txt +15 -0
  21. data/test/fixtures/{valid → invalid}/with_structure.txt +2 -2
  22. data/test/fixtures/valid/multiple_tracks_same_sound.txt +2 -1
  23. data/test/fixtures/valid/optimize_pattern_collision.txt +4 -5
  24. data/test/fixtures/valid/track_with_spaces.txt +13 -0
  25. data/test/includes.rb +1 -4
  26. data/test/integration_test.rb +5 -5
  27. data/test/kit_builder_test.rb +52 -0
  28. data/test/kit_test.rb +18 -141
  29. data/test/pattern_test.rb +66 -1
  30. data/test/song_swinger_test.rb +2 -2
  31. data/test/song_test.rb +9 -33
  32. data/test/songoptimizer_test.rb +18 -18
  33. data/test/songparser_test.rb +20 -10
  34. data/test/track_test.rb +23 -9
  35. metadata +26 -31
  36. data/ext/mkrf_conf.rb +0 -28
  37. data/test/fixtures/invalid/template.txt +0 -31
  38. data/test/fixtures/valid/foo.txt +0 -18
  39. data/test/sounds/bass.wav +0 -0
  40. data/test/sounds/bass2.wav +0 -0
  41. data/test/sounds/sine-mono-8bit.wav +0 -0
  42. data/test/sounds/tone.wav +0 -0
@@ -0,0 +1,74 @@
1
+ module Beats
2
+ class KitBuilder
3
+ # Raised when trying to load a sound file which can't be found at the path specified
4
+ class SoundFileNotFoundError < RuntimeError; end
5
+
6
+ # Raised when trying to load a sound file which either isn't actually a sound file, or
7
+ # is in an unsupported format.
8
+ class InvalidSoundFormatError < RuntimeError; end
9
+
10
+ BITS_PER_SAMPLE = 16
11
+ SAMPLE_FORMAT = "pcm_#{BITS_PER_SAMPLE}".to_sym
12
+ SAMPLE_RATE = 44100
13
+
14
+ def initialize(base_path)
15
+ @base_path = base_path
16
+ @labels_to_filenames = {}
17
+ end
18
+
19
+ def add_item(label, filename)
20
+ @labels_to_filenames[label] = absolute_file_name(filename)
21
+ end
22
+
23
+ def has_label?(label)
24
+ @labels_to_filenames.keys.include?(label)
25
+ end
26
+
27
+ def build_kit
28
+ # Load each sample buffer
29
+ filenames_to_buffers = {}
30
+ @labels_to_filenames.values.uniq.each do |filename|
31
+ filenames_to_buffers[filename] = load_sample_buffer(filename)
32
+ end
33
+
34
+ # Convert each buffer to the same sample format
35
+ num_channels = filenames_to_buffers.values.map(&:channels).max || 1
36
+ canonical_format = WaveFile::Format.new(num_channels, SAMPLE_FORMAT, SAMPLE_RATE)
37
+ filenames_to_buffers.values.each {|buffer| buffer.convert!(canonical_format) }
38
+
39
+ labels_to_buffers = {}
40
+ @labels_to_filenames.each do |label, filename|
41
+ labels_to_buffers[label] = filenames_to_buffers[filename].samples
42
+ end
43
+ labels_to_buffers[Kit::PLACEHOLDER_TRACK_NAME] = []
44
+
45
+ Kit.new(labels_to_buffers, num_channels, BITS_PER_SAMPLE)
46
+ end
47
+
48
+ private
49
+
50
+ # Converts relative path into absolute path. Note that this will also handle
51
+ # expanding ~ on platforms that support that.
52
+ def absolute_file_name(filename)
53
+ File.expand_path(filename, @base_path)
54
+ end
55
+
56
+ def load_sample_buffer(filename)
57
+ sample_buffer = nil
58
+
59
+ begin
60
+ reader = WaveFile::Reader.new(filename)
61
+ reader.each_buffer(reader.total_sample_frames) do |buffer|
62
+ sample_buffer = buffer
63
+ end
64
+ rescue Errno::ENOENT
65
+ raise SoundFileNotFoundError, "Sound file #{filename} not found."
66
+ rescue StandardError
67
+ raise InvalidSoundFormatError, "Sound file #{filename} is either not a sound file, " +
68
+ "or is in an unsupported format. BEATS can handle 8, 16, 24, or 32-bit PCM *.wav files."
69
+ end
70
+
71
+ sample_buffer
72
+ end
73
+ end
74
+ end
@@ -5,8 +5,6 @@ module Beats
5
5
  # This object is like sheet music; the AudioEngine is responsible creating actual
6
6
  # audio data for a Pattern (with the help of a Kit).
7
7
  class Pattern
8
- FLOW_TRACK_NAME = "flow"
9
-
10
8
  def initialize(name)
11
9
  @name = name
12
10
  @tracks = {}
@@ -21,7 +19,7 @@ module Beats
21
19
  # If the new track is longer than any of the previously added tracks,
22
20
  # pad the other tracks with trailing . to make them all the same length.
23
21
  # Necessary to prevent incorrect overflow calculations for tracks.
24
- longest_track_length = step_count()
22
+ longest_track_length = step_count
25
23
  @tracks.values.each do |track|
26
24
  if track.rhythm.length < longest_track_length
27
25
  track.rhythm += "." * (longest_track_length - track.rhythm.length)
@@ -49,25 +47,7 @@ module Beats
49
47
  @tracks.length == other_pattern.tracks.length
50
48
  end
51
49
 
52
- # Returns a YAML representation of the Pattern. Produces nicer looking output than the default
53
- # version of to_yaml().
54
- def to_yaml
55
- longest_track_name_length =
56
- @tracks.keys.inject(0) do |max_length, name|
57
- (name.to_s.length > max_length) ? name.to_s.length : max_length
58
- end
59
- ljust_amount = longest_track_name_length + 7
60
-
61
- yaml = "#{@name.to_s.capitalize}:\n"
62
- @tracks.keys.sort.each do |track_name|
63
- yaml += " - #{track_name}:".ljust(ljust_amount)
64
- yaml += "#{@tracks[track_name].rhythm}\n"
65
- end
66
-
67
- yaml
68
- end
69
-
70
- attr_accessor :tracks, :name
50
+ attr_reader :tracks, :name
71
51
 
72
52
  private
73
53
 
@@ -1,7 +1,4 @@
1
1
  module Beats
2
- class InvalidTempoError < RuntimeError; end
3
-
4
-
5
2
  # Domain object which models the 'sheet music' for a full song. Models the Patterns
6
3
  # that should be played, in which order (i.e. the flow), and at which tempo.
7
4
  #
@@ -10,6 +7,8 @@ module Beats
10
7
  # A Kit provides the sample data for each of these sounds. With a Song and a Kit
11
8
  # the AudioEngine can produce the audio data that is saved to disk.
12
9
  class Song
10
+ class InvalidTempoError < RuntimeError; end
11
+
13
12
  DEFAULT_TEMPO = 120
14
13
 
15
14
  def initialize
@@ -21,7 +20,6 @@ module Beats
21
20
  # Adds a new pattern to the song, with the specified name.
22
21
  def pattern(name)
23
22
  @patterns[name] = Pattern.new(name)
24
- @patterns[name]
25
23
  end
26
24
 
27
25
 
@@ -52,7 +50,7 @@ module Beats
52
50
  end
53
51
 
54
52
  def tempo=(new_tempo)
55
- unless (new_tempo.class == Fixnum || new_tempo.class == Float) && new_tempo > 0
53
+ unless (new_tempo.is_a?(Integer) || new_tempo.class == Float) && new_tempo > 0
56
54
  raise InvalidTempoError, "Invalid tempo: '#{new_tempo}'. Tempo must be a number greater than 0."
57
55
  end
58
56
 
@@ -61,7 +59,7 @@ module Beats
61
59
 
62
60
  # Returns a new Song that is identical but with no patterns or flow.
63
61
  def copy_ignoring_patterns_and_flow
64
- copy = Song.new()
62
+ copy = Song.new
65
63
  copy.tempo = @tempo
66
64
 
67
65
  copy
@@ -75,7 +73,7 @@ module Beats
75
73
  track_names = track_names()
76
74
 
77
75
  track_names.each do |track_name|
78
- new_song = copy_ignoring_patterns_and_flow()
76
+ new_song = copy_ignoring_patterns_and_flow
79
77
 
80
78
  @patterns.each do |name, original_pattern|
81
79
  new_pattern = new_song.pattern(name)
@@ -102,23 +100,6 @@ module Beats
102
100
  @patterns.reject! {|k, pattern| !@flow.member?(pattern.name) }
103
101
  end
104
102
 
105
- # Serializes the current Song to a YAML string. This string can then be used to construct a new Song
106
- # using the SongParser class. This lets you save a Song to disk, to be re-loaded later. Produces nicer
107
- # looking output than the default version of to_yaml().
108
- def to_yaml(kit)
109
- # This implementation intentionally builds up a YAML string manually instead of using YAML::dump().
110
- # Ruby 1.8 makes it difficult to ensure a consistent ordering of hash keys, which makes the output ugly
111
- # and also hard to test.
112
-
113
- yaml_output = "Song:\n"
114
- yaml_output += " Tempo: #{@tempo}\n"
115
- yaml_output += flow_to_yaml()
116
- yaml_output += kit.to_yaml(2)
117
- yaml_output += patterns_to_yaml()
118
-
119
- yaml_output
120
- end
121
-
122
103
  attr_reader :patterns, :tempo
123
104
  attr_accessor :flow
124
105
 
@@ -127,36 +108,5 @@ module Beats
127
108
  def longest_length_in_array(arr)
128
109
  arr.inject(0) {|max_length, name| [name.to_s.length, max_length].max }
129
110
  end
130
-
131
- def flow_to_yaml
132
- yaml_output = " Flow:\n"
133
- ljust_amount = longest_length_in_array(@flow) + 1 # The +1 is for the trailing ":"
134
- previous = nil
135
- count = 0
136
- @flow.each do |pattern_name|
137
- if pattern_name == previous || previous.nil?
138
- count += 1
139
- else
140
- yaml_output += " - #{(previous.to_s.capitalize + ':').ljust(ljust_amount)} x#{count}\n"
141
- count = 1
142
- end
143
- previous = pattern_name
144
- end
145
- yaml_output += " - #{(previous.to_s.capitalize + ':').ljust(ljust_amount)} x#{count}\n"
146
-
147
- yaml_output
148
- end
149
-
150
- def patterns_to_yaml
151
- yaml_output = ""
152
-
153
- # Sort to ensure a consistent order, to make testing easier
154
- pattern_names = @patterns.keys.map {|key| key.to_s} # Ruby 1.8 can't sort symbols...
155
- pattern_names.sort.each do |pattern_name|
156
- yaml_output += "\n" + @patterns[pattern_name.to_sym].to_yaml()
157
- end
158
-
159
- yaml_output
160
- end
161
111
  end
162
112
  end
@@ -20,7 +20,7 @@ module Beats
20
20
  # generated faster.
21
21
  def optimize(original_song, max_pattern_length)
22
22
  # 1.) Create a new song, cloned from the original
23
- optimized_song = original_song.copy_ignoring_patterns_and_flow()
23
+ optimized_song = original_song.copy_ignoring_patterns_and_flow
24
24
 
25
25
  # 2.) Subdivide patterns
26
26
  optimized_song = subdivide_song_patterns(original_song, optimized_song, max_pattern_length)
@@ -82,7 +82,7 @@ module Beats
82
82
  # Otherwise, this pattern will have no steps, and no sound will be generated,
83
83
  # causing the pattern to be "compacted away".
84
84
  if new_pattern.tracks.empty?
85
- new_pattern.track("placeholder", blank_track_pattern)
85
+ new_pattern.track(Kit::PLACEHOLDER_TRACK_NAME, blank_track_pattern)
86
86
  end
87
87
 
88
88
  step_index += max_pattern_length
@@ -122,7 +122,7 @@ module Beats
122
122
  song.flow = new_flow
123
123
 
124
124
  # This isn't strictly necessary, but makes resulting songs easier to read for debugging purposes.
125
- song.remove_unused_patterns()
125
+ song.remove_unused_patterns
126
126
 
127
127
  song
128
128
  end
@@ -1,7 +1,4 @@
1
1
  module Beats
2
- class SongParseError < RuntimeError; end
3
-
4
-
5
2
  # This class is used to parse a raw YAML song definition into domain objects (i.e.
6
3
  # Song, Pattern, Track, and Kit). These domain objects can then be used by AudioEngine
7
4
  # to generate the actual audio data that is saved to disk.
@@ -9,11 +6,7 @@ module Beats
9
6
  # The sole public method is parse(). It takes a raw YAML string and returns a Song and
10
7
  # Kit object (or raises an error if the YAML string couldn't be parsed correctly).
11
8
  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"
9
+ class ParseError < RuntimeError; end
17
10
 
18
11
  NO_SONG_HEADER_ERROR_MSG =
19
12
  "Song must have a header. Here's an example:
@@ -43,17 +36,17 @@ module Beats
43
36
  unless raw_song_components[:tempo].nil?
44
37
  song.tempo = raw_song_components[:tempo]
45
38
  end
46
- rescue InvalidTempoError => detail
47
- raise SongParseError, "#{detail}"
39
+ rescue Song::InvalidTempoError => detail
40
+ raise ParseError, "#{detail}"
48
41
  end
49
42
 
50
43
  # 2.) Build the kit
51
44
  begin
52
45
  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}"
46
+ rescue KitBuilder::SoundFileNotFoundError => detail
47
+ raise ParseError, "#{detail}"
48
+ rescue KitBuilder::InvalidSoundFormatError => detail
49
+ raise ParseError, "#{detail}"
57
50
  end
58
51
 
59
52
  # 3.) Load patterns
@@ -61,7 +54,7 @@ module Beats
61
54
 
62
55
  # 4.) Set flow
63
56
  if raw_song_components[:flow].nil?
64
- raise SongParseError, "Song must have a Flow section in the header."
57
+ raise ParseError, "Song must have a Flow section in the header."
65
58
  else
66
59
  set_song_flow(song, raw_song_components[:flow])
67
60
  end
@@ -70,8 +63,8 @@ module Beats
70
63
  if raw_song_components[:swing]
71
64
  begin
72
65
  song = Transforms::SongSwinger.transform(song, raw_song_components[:swing])
73
- rescue Transforms::InvalidSwingRateError => detail
74
- raise SongParseError, "#{detail}"
66
+ rescue Transforms::SongSwinger::InvalidSwingRateError => detail
67
+ raise ParseError, "#{detail}"
75
68
  end
76
69
  end
77
70
 
@@ -85,8 +78,10 @@ module Beats
85
78
  def hashify_raw_yaml(raw_yaml_string)
86
79
  begin
87
80
  raw_song_definition = YAML.load(raw_yaml_string)
81
+ rescue Psych::SyntaxError => detail
82
+ raise ParseError, "Syntax error in YAML file: #{detail}"
88
83
  rescue ArgumentError => detail
89
- raise SongParseError, "Syntax error in YAML file"
84
+ raise ParseError, "Syntax error in YAML file: #{detail}"
90
85
  end
91
86
 
92
87
  raw_song_components = {}
@@ -95,23 +90,13 @@ module Beats
95
90
  unless raw_song_components[:full_definition]["song"].nil?
96
91
  raw_song_components[:header] = downcase_hash_keys(raw_song_components[:full_definition]["song"])
97
92
  else
98
- raise SongParseError, NO_SONG_HEADER_ERROR_MSG
93
+ raise ParseError, NO_SONG_HEADER_ERROR_MSG
99
94
  end
100
95
  raw_song_components[:tempo] = raw_song_components[:header]["tempo"]
101
96
  raw_song_components[:folder] = raw_song_components[:header]["folder"]
102
97
  raw_song_components[:kit] = raw_song_components[:header]["kit"]
103
98
 
104
- raw_flow = raw_song_components[:header]["flow"]
105
- raw_structure = raw_song_components[:header]["structure"]
106
- unless raw_flow.nil?
107
- raw_song_components[:flow] = raw_flow
108
- else
109
- unless raw_structure.nil?
110
- puts DONT_USE_STRUCTURE_WARNING
111
- end
112
-
113
- raw_song_components[:flow] = raw_structure
114
- end
99
+ raw_song_components[:flow] = raw_song_components[:header]["flow"]
115
100
 
116
101
  raw_song_components[:swing] = raw_song_components[:header]["swing"]
117
102
  raw_song_components[:patterns] = raw_song_components[:full_definition].reject {|k, v| k == "song"}
@@ -119,23 +104,19 @@ module Beats
119
104
  return raw_song_components
120
105
  end
121
106
 
122
-
123
107
  def build_kit(base_path, raw_kit, raw_patterns)
124
- kit_items = {}
108
+ kit_builder = KitBuilder.new(base_path)
125
109
 
126
110
  # Add sounds defined in the Kit section of the song header
127
111
  # Converts [{a=>1}, {b=>2}, {c=>3}] from raw YAML to {a=>1, b=>2, c=>3}
128
112
  # TODO: Raise error is same name is defined more than once in the Kit
129
113
  unless raw_kit.nil?
130
114
  raw_kit.each do |kit_item|
131
- kit_items[kit_item.keys.first] = kit_item.values.first
115
+ kit_builder.add_item(kit_item.keys.first, kit_item.values.first)
132
116
  end
133
117
  end
134
118
 
135
119
  # Add sounds not defined in Kit section, but used in individual tracks
136
- # TODO Investigate detecting duplicate keys already defined in the Kit section, as this could possibly
137
- # result in a performance improvement when the sound has to be converted to a different bit rate/num channels,
138
- # as well as use less memory.
139
120
  raw_patterns.keys.each do |key|
140
121
  track_list = raw_patterns[key]
141
122
 
@@ -144,30 +125,28 @@ module Beats
144
125
  track_name = track_definition.keys.first
145
126
  track_path = track_name
146
127
 
147
- if track_name != Pattern::FLOW_TRACK_NAME && kit_items[track_name].nil?
148
- kit_items[track_name] = track_path
128
+ if !kit_builder.has_label?(track_name)
129
+ kit_builder.add_item(track_name, track_path)
149
130
  end
150
131
  end
151
132
  end
152
133
  end
153
134
 
154
- Kit.new(base_path, kit_items)
135
+ kit_builder.build_kit
155
136
  end
156
137
 
157
-
158
138
  def add_patterns_to_song(song, raw_patterns)
159
139
  raw_patterns.keys.each do |key|
160
140
  new_pattern = song.pattern key.to_sym
161
141
 
162
142
  track_list = raw_patterns[key]
163
- # TODO Also raise error if only there is only 1 track and it's a flow track
143
+
164
144
  if track_list.nil?
165
145
  # TODO: Use correct capitalization of pattern name in error message
166
146
  # TODO: Possibly allow if pattern not referenced in the Flow, or has 0 repeats?
167
- raise SongParseError, "Pattern '#{key}' has no tracks. It needs at least one."
147
+ raise ParseError, "Pattern '#{key}' has no tracks. It needs at least one."
168
148
  end
169
149
 
170
- # TODO: What if there is more than one flow? Raise error, or have last one win?
171
150
  track_list.each do |track_definition|
172
151
  track_name = track_definition.keys.first
173
152
 
@@ -197,16 +176,16 @@ module Beats
197
176
  multiples = multiples_str.to_i
198
177
 
199
178
  unless multiples_str.match(/[^0-9]/).nil?
200
- raise SongParseError,
179
+ raise ParseError,
201
180
  "'#{multiples_str}' is an invalid number of repeats for pattern '#{pattern_name}'. Number of repeats should be a whole number."
202
181
  else
203
182
  if multiples < 0
204
- raise SongParseError, "'#{multiples_str}' is an invalid number of repeats for pattern '#{pattern_name}'. Must be 0 or greater."
183
+ raise ParseError, "'#{multiples_str}' is an invalid number of repeats for pattern '#{pattern_name}'. Must be 0 or greater."
205
184
  elsif multiples > 0 && !song.patterns.has_key?(pattern_name_sym)
206
185
  # This test is purposefully designed to only throw an error if the number of repeats is greater
207
186
  # than 0. This allows you to specify an undefined pattern in the flow with "x0" repeats.
208
187
  # This can be convenient for defining the flow before all patterns have been added to the song file.
209
- raise SongParseError, "Song flow includes non-existent pattern: #{pattern_name}."
188
+ raise ParseError, "Song flow includes non-existent pattern: #{pattern_name}."
210
189
  end
211
190
  end
212
191
 
@@ -1,57 +1,46 @@
1
1
  module Beats
2
- class InvalidRhythmError < RuntimeError; end
3
-
4
-
5
2
  # Domain object which models a kit sound playing a rhythm. For example,
6
3
  # a bass drum playing every quarter note for two measures.
7
4
  #
8
5
  # This object is like sheet music; the AudioEngine is responsible creating actual
9
6
  # audio data for a Track (with the help of a Kit).
10
7
  class Track
8
+ class InvalidRhythmError < RuntimeError; end
9
+
11
10
  REST = "."
12
11
  BEAT = "X"
13
12
  BARLINE = "|"
13
+ SPACE = " "
14
+ DISALLOWED_CHARACTERS = /[^X\.]/ # I.e., anything not an 'X' or a '.'
14
15
 
15
16
  def initialize(name, rhythm)
16
- # TODO: Add validation for input parameters
17
17
  @name = name
18
18
  self.rhythm = rhythm
19
19
  end
20
20
 
21
- # TODO: What to have this invoked when setting like this?
22
- # track.rhythm[x..y] = whatever
23
21
  def rhythm=(rhythm)
24
22
  @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
23
+ @rhythm = @rhythm.delete(SPACE)
24
+ @trigger_step_lengths = calculate_trigger_step_lengths
48
25
  end
49
26
 
50
27
  def step_count
51
28
  @rhythm.length
52
29
  end
53
30
 
54
- attr_accessor :name
55
- attr_reader :rhythm, :beats
31
+ attr_reader :name, :rhythm, :trigger_step_lengths
32
+
33
+ private
34
+
35
+ def calculate_trigger_step_lengths
36
+ if @rhythm.match(DISALLOWED_CHARACTERS)
37
+ raise InvalidRhythmError, "Track #{@name} has an invalid rhythm: '#{rhythm}'. Can only contain '#{BEAT}', '#{REST}', '#{BARLINE}', or ' '"
38
+ end
39
+
40
+ trigger_step_lengths = @rhythm.scan(/X?\.*/)[0..-2].map(&:length)
41
+ trigger_step_lengths.unshift(0) unless @rhythm.start_with?(REST)
42
+
43
+ trigger_step_lengths
44
+ end
56
45
  end
57
46
  end