beats 1.3.0 → 2.0.0

Sign up to get free protection for your applications and to get access to all the features.
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