beats 1.2.0 → 1.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (69) hide show
  1. data/LICENSE +1 -1
  2. data/README.markdown +28 -10
  3. data/bin/beats +9 -7
  4. data/lib/audioengine.rb +172 -0
  5. data/lib/audioutils.rb +73 -0
  6. data/lib/beats.rb +14 -15
  7. data/lib/beatswavefile.rb +17 -37
  8. data/lib/kit.rb +148 -71
  9. data/lib/pattern.rb +20 -117
  10. data/lib/patternexpander.rb +111 -0
  11. data/lib/song.rb +78 -132
  12. data/lib/songoptimizer.rb +29 -33
  13. data/lib/songparser.rb +70 -45
  14. data/lib/track.rb +11 -82
  15. data/test/audioengine_test.rb +261 -0
  16. data/test/audioutils_test.rb +45 -0
  17. data/test/fixtures/expected_output/example_split_mono_16-hh_closed.wav +0 -0
  18. data/test/{examples/split-agogo_high.wav → fixtures/expected_output/example_split_mono_16-hh_closed2.wav} +0 -0
  19. data/test/fixtures/expected_output/example_split_mono_8-hh_closed.wav +0 -0
  20. data/test/{examples/split-tom4.wav → fixtures/expected_output/example_split_mono_8-hh_closed2.wav} +0 -0
  21. data/test/fixtures/expected_output/example_split_stereo_16-hh_closed.wav +0 -0
  22. data/test/fixtures/expected_output/example_split_stereo_16-hh_closed2.wav +0 -0
  23. data/test/fixtures/expected_output/example_split_stereo_8-hh_closed.wav +0 -0
  24. data/test/fixtures/expected_output/example_split_stereo_8-hh_closed2.wav +0 -0
  25. data/test/fixtures/invalid/{bad_structure.txt → bad_flow.txt} +2 -2
  26. data/test/fixtures/invalid/bad_repeat_count.txt +1 -1
  27. data/test/fixtures/invalid/bad_rhythm.txt +1 -1
  28. data/test/fixtures/invalid/bad_tempo.txt +1 -1
  29. data/test/fixtures/invalid/{no_structure.txt → no_flow.txt} +1 -1
  30. data/test/fixtures/invalid/pattern_with_no_tracks.txt +1 -1
  31. data/test/fixtures/invalid/sound_in_kit_not_found.txt +1 -1
  32. data/test/fixtures/invalid/sound_in_kit_wrong_format.txt +10 -0
  33. data/test/fixtures/invalid/sound_in_track_not_found.txt +1 -1
  34. data/test/fixtures/invalid/sound_in_track_wrong_format.txt +8 -0
  35. data/test/fixtures/invalid/template.txt +1 -1
  36. data/test/fixtures/valid/example_mono_16.txt +5 -3
  37. data/test/fixtures/valid/example_mono_8.txt +5 -3
  38. data/test/fixtures/valid/example_no_kit.txt +1 -1
  39. data/test/fixtures/valid/example_stereo_16.txt +7 -4
  40. data/test/fixtures/valid/example_stereo_8.txt +5 -3
  41. data/test/fixtures/valid/example_with_empty_track.txt +1 -1
  42. data/test/fixtures/valid/example_with_kit.txt +1 -1
  43. data/test/fixtures/valid/multiple_tracks_same_sound.txt +33 -0
  44. data/test/fixtures/valid/no_tempo.txt +1 -1
  45. data/test/fixtures/valid/optimize_pattern_collision.txt +28 -0
  46. data/test/fixtures/valid/pattern_with_overflow.txt +1 -1
  47. data/test/fixtures/valid/repeats_not_specified.txt +2 -2
  48. data/test/fixtures/valid/with_structure.txt +10 -0
  49. data/test/fixtures/yaml/song_yaml.txt +5 -5
  50. data/test/includes.rb +4 -2
  51. data/test/integration.rb +3 -3
  52. data/test/kit_test.rb +136 -109
  53. data/test/pattern_test.rb +31 -131
  54. data/test/patternexpander_test.rb +142 -0
  55. data/test/song_test.rb +104 -102
  56. data/test/songoptimizer_test.rb +52 -38
  57. data/test/songparser_test.rb +79 -46
  58. data/test/sounds/composite_snare_mono_8_tom3_mono_16_mono_16.wav +0 -0
  59. data/test/sounds/composite_snare_mono_8_tom3_mono_8_mono_16.wav +0 -0
  60. data/test/sounds/composite_snare_stereo_16_tom3_mono_16_stereo_16.wav +0 -0
  61. data/test/sounds/composite_snare_stereo_8_tom3_mono_16_stereo_16.wav +0 -0
  62. data/test/track_test.rb +30 -185
  63. metadata +56 -24
  64. data/lib/songsplitter.rb +0 -38
  65. data/test/examples/combined.wav +0 -0
  66. data/test/examples/split-bass.wav +0 -0
  67. data/test/examples/split-hh_closed.wav +0 -0
  68. data/test/examples/split-snare.wav +0 -0
  69. data/test/examples/split-tom2.wav +0 -0
data/lib/kit.rb CHANGED
@@ -1,93 +1,93 @@
1
1
  # Raised when trying to load a sound file which can't be found at the path specified
2
- class SoundNotFoundError < RuntimeError; end
2
+ class SoundFileNotFoundError < RuntimeError; end
3
3
 
4
- # This class keeps track of the sounds that are used in a song. It provides a
5
- # central place for storing sound data, and most usefully, handles converting
6
- # sounds in different formats to a standard format.
7
- #
8
- # For example, if a song requires a sound that is mono/8-bit, another that is
9
- # stereo/8-bit, and another that is stereo/16-bit, it can't mix them together
10
- # because they are in different formats. Kit however automatically handles the
11
- # details of converting them to a common format. If you add sounds to the kit
12
- # using add(), sounds that you get using get_sample_data() will be in a common
13
- # format.
4
+ # Raised when trying to load a sound file which either isn't actually a sound file, or
5
+ # is in an unsupported format.
6
+ class InvalidSoundFormatError < RuntimeError; end
7
+
8
+
9
+ # This class provides a repository for the sounds used in a song. Most usefully, it
10
+ # also handles converting the sounds to a common format. For example, if a song requires
11
+ # a sound that is mono/8-bit, another that is stereo/8-bit, and another that is
12
+ # stereo/16-bit, they have to be converted to a common format before they can be used
13
+ # together. Kit handles this conversion; all sounds retrieved using
14
+ # get_sample_data() will be in a common format.
15
+ #
16
+ # Sounds can only be added at initialization. During initialization, the sample data
17
+ # for each sound is loaded into memory, and converted to the common format if necessary.
18
+ # This format is:
19
+ #
20
+ # Bits per sample: 16
21
+ # Sample rate: 44100
22
+ # Channels: Stereo, unless all of the kit sounds are mono.
14
23
  #
15
- # All sounds returned by get_sample_data() will be 16-bit. All sounds will be
16
- # either mono or stereo; if at least one added sound is stereo then all sounds
17
- # will be stereo. So for example if a mono/8-bit, stereo/8-bit, and stereo/16-bit
18
- # sound are added, when you retrieve each one using get_sample_data() they will
19
- # be stereo/16-bit.
24
+ # For example if the kit has these sounds:
20
25
  #
21
- # Note that this means that each time a new sound is added to the Kit, the common
22
- # format might change, if the incoming sound has a greater number of channels than
23
- # any of the previously added sounds. Therefore, all of the sounds
24
- # used by a Song should be added to the Kit before generation begins. If you
25
- # create Song objects by using SongParser, this will be taken care of for you (as
26
- # long as you don't modify the Kit afterward).
26
+ # my_sound_1.wav: mono, 16-bit
27
+ # my_sound_2.wav: stereo, 8-bit
28
+ # my_sound_3.wav: mono, 8-bit
29
+ #
30
+ # they will all be converted to stereo/16-bit during initialization.
27
31
  class Kit
28
- PATH_SEPARATOR = File.const_get("SEPARATOR")
29
-
30
- # Creates a new Kit object. base_path indicates the folder from which sound files
31
- # with relative file paths will be loaded from.
32
- def initialize(base_path)
32
+ def initialize(base_path, kit_items)
33
33
  @base_path = base_path
34
34
  @label_mappings = {}
35
- @sounds = {}
35
+ @sound_bank = {}
36
36
  @num_channels = 1
37
37
  @bits_per_sample = 16 # Only use 16-bit files as output. Supporting 8-bit output
38
38
  # means extra complication for no real gain (I'm skeptical
39
- # anyone would explicitly want 8-bit output instead of 16-bit.
39
+ # anyone would explicitly want 8-bit output instead of 16-bit).
40
+
41
+ load_sounds(base_path, kit_items)
40
42
  end
41
43
 
42
- # Adds a new sound to the kit.
43
- def add(name, path)
44
- unless @sounds.has_key? name
45
- path_is_absolute = path.start_with?(PATH_SEPARATOR)
46
- if path_is_absolute
47
- full_path = path
48
- else
49
- full_path = @base_path + PATH_SEPARATOR + path
50
- end
51
-
52
- begin
53
- wavefile = WaveFile.open(full_path)
54
- rescue
55
- # TODO: Raise different error if sound is in an unsupported format
56
- raise SoundNotFoundError, "Sound file #{full_path} not found."
57
- end
58
-
59
- @sounds[name] = wavefile
60
- if name != path
61
- @label_mappings[name] = path
62
- end
63
-
64
- if wavefile.num_channels > @num_channels
65
- @num_channels = wavefile.num_channels
66
- end
44
+ # Returns the sample data for a sound contained in the Kit. If the all sounds in the
45
+ # kit are mono, then this will be a flat Array of Fixnums between -32768 and 32767.
46
+ # Otherwise, this will be an Array of Fixnums pairs between -32768 and 32767.
47
+ #
48
+ # label - The name of the sound to get sample data for. If the sound was defined in
49
+ # the Kit section of a song file, this will generally be a descriptive label
50
+ # such as "bass" or "snare". If defined in a track but not the kit, it will
51
+ # generally be a file name such as "my_sounds/hihat/hi_hat.wav".
52
+ #
53
+ # Examples
54
+ #
55
+ # # If @num_channels is 1, a flat Array of Fixnums:
56
+ # get_sample_data("bass")
57
+ # # => [154, 7023, 8132, 2622, -132, 34, ..., -6702]
58
+ #
59
+ # # If @num_channels is 2, a Array of Fixnums pairs:
60
+ # get_sample_data("snare")
61
+ # # => [[57, 1265], [-452, 10543], [-2531, 12643], [-6372, 11653], ..., [5482, 25673]]
62
+ #
63
+ # Returns the sample data Array for the sound bound to label.
64
+ def get_sample_data(label)
65
+ if label == "placeholder"
66
+ return []
67
67
  end
68
- end
69
-
70
- # Returns the sample data (as an Array) for a sound contained in the Kit.
71
- # Raises an error if the sound doesn't exist in the Kit.
72
- def get_sample_data(name)
73
- wavefile = @sounds[name]
68
+
69
+ sample_data = @sound_bank[label]
74
70
 
75
- if wavefile == nil
76
- raise StandardError, "Kit doesn't contain sound '#{name}'."
71
+ if sample_data == nil
72
+ # TODO: Should we really throw an exception here rather than just returning nil?
73
+ raise StandardError, "Kit doesn't contain sound '#{label}'."
77
74
  else
78
- wavefile.num_channels = @num_channels
79
- wavefile.bits_per_sample = @bits_per_sample
80
-
81
- return wavefile.sample_data
75
+ return sample_data
82
76
  end
83
77
  end
84
78
 
85
- # Returns the number of sounds currently contained in the kit.
86
- def size
87
- return @sounds.length
79
+ def scale!(scale_factor)
80
+ @sound_bank.each do |label, sample_array|
81
+ @sound_bank[label] = AudioUtils.scale(sample_array, @num_channels, scale_factor)
82
+ end
88
83
  end
89
-
90
- # Produces nicer looking output than the default version of to_yaml().
84
+
85
+ # Returns a YAML representation of the Kit. Produces nicer looking output than the default version
86
+ # of to_yaml().
87
+ #
88
+ # indent_space_count - The number of spaces to indent each line in the output (default: 0).
89
+ #
90
+ # Returns a String representation of the Kit in YAML format.
91
91
  def to_yaml(indent_space_count = 0)
92
92
  yaml = ""
93
93
  longest_label_mapping_length =
@@ -107,4 +107,81 @@ class Kit
107
107
  end
108
108
 
109
109
  attr_reader :base_path, :label_mappings, :bits_per_sample, :num_channels
110
+
111
+ private
112
+
113
+ def load_sounds(base_path, kit_items)
114
+ # Set label mappings
115
+ kit_items.each do |label, sound_file_names|
116
+ if sound_file_names.class == Array
117
+ raise StandardError, "Composite sounds aren't allowed (yet...)"
118
+ end
119
+
120
+ unless label == sound_file_names
121
+ @label_mappings[label] = sound_file_names
122
+ end
123
+ end
124
+
125
+ kit_items = make_file_names_absolute(kit_items)
126
+ raw_sounds = load_raw_sounds(kit_items)
127
+
128
+ # Convert each sound to a common format
129
+ raw_sounds.values.each do |wavefile|
130
+ wavefile.num_channels = @num_channels
131
+ wavefile.bits_per_sample = @bits_per_sample
132
+ end
133
+
134
+ # If necessary, mix component sounds into a composite
135
+ kit_items.each do |label, sound_file_names|
136
+ @sound_bank[label] = mixdown(sound_file_names, raw_sounds)
137
+ end
138
+ end
139
+
140
+ def get_absolute_path(base_path, sound_file_name)
141
+ path_is_absolute = sound_file_name.start_with?(File::SEPARATOR)
142
+ return path_is_absolute ? sound_file_name : (base_path + File::SEPARATOR + sound_file_name)
143
+ end
144
+
145
+ def make_file_names_absolute(kit_items)
146
+ kit_items.each do |label, sound_file_names|
147
+ unless sound_file_names.class == Array
148
+ sound_file_names = [sound_file_names]
149
+ end
150
+
151
+ sound_file_names.map! {|sound_file_name| get_absolute_path(base_path, sound_file_name)}
152
+ kit_items[label] = sound_file_names
153
+ end
154
+
155
+ return kit_items
156
+ end
157
+
158
+ # Load all sound files, bailing if any are invalid
159
+ def load_raw_sounds(kit_items)
160
+ raw_sounds = {}
161
+ kit_items.values.flatten.each do |sound_file_name|
162
+ begin
163
+ wavefile = WaveFile.open(sound_file_name)
164
+ rescue Errno::ENOENT
165
+ raise SoundFileNotFoundError, "Sound file #{sound_file_name} not found."
166
+ rescue StandardError
167
+ raise InvalidSoundFormatError, "Sound file #{sound_file_name} is either not a sound file, " +
168
+ "or is in an unsupported format. BEATS can handle 8 or 16-bit *.wav files."
169
+ end
170
+ @num_channels = [@num_channels, wavefile.num_channels].max
171
+ raw_sounds[sound_file_name] = wavefile
172
+ end
173
+
174
+ return raw_sounds
175
+ end
176
+
177
+ def mixdown(sound_file_names, raw_sounds)
178
+ sample_arrays = []
179
+ sound_file_names.each do |sound_file_name|
180
+ sample_arrays << raw_sounds[sound_file_name].sample_data
181
+ end
182
+
183
+ composited_sample_data = AudioUtils.composite(sample_arrays, @num_channels)
184
+
185
+ return AudioUtils.scale(composited_sample_data, @num_channels, sound_file_names.length)
186
+ end
110
187
  end
@@ -1,18 +1,21 @@
1
1
  class Pattern
2
+ FLOW_TRACK_NAME = "flow"
3
+
2
4
  def initialize(name)
3
5
  @name = name
4
6
  @tracks = {}
5
7
  end
6
8
 
7
9
  # Adds a new track to the pattern.
8
- def track(name, wave_data, rhythm)
9
- new_track = Track.new(name, wave_data, rhythm)
10
- @tracks[new_track.name] = new_track
11
-
10
+ def track(name, rhythm)
11
+ track_key = unique_track_name(name)
12
+ new_track = Track.new(name, rhythm)
13
+ @tracks[track_key] = new_track
14
+
12
15
  # If the new track is longer than any of the previously added tracks,
13
16
  # pad the other tracks with trailing . to make them all the same length.
14
17
  # Necessary to prevent incorrect overflow calculations for tracks.
15
- longest_track_length = tick_count()
18
+ longest_track_length = step_count()
16
19
  @tracks.values.each do |track|
17
20
  if track.rhythm.length < longest_track_length
18
21
  track.rhythm += "." * (longest_track_length - track.rhythm.length)
@@ -22,33 +25,9 @@ class Pattern
22
25
  return new_track
23
26
  end
24
27
 
25
- # The number of samples required for the pattern at the given tempo. DOES NOT include samples
26
- # necessary for sound that overflows past the last tick of the pattern.
27
- def sample_length(tick_sample_length)
28
- @tracks.keys.collect {|track_name| @tracks[track_name].sample_length(tick_sample_length) }.max || 0
29
- end
30
-
31
- # The number of samples required for the pattern at the given tempo. Include sound overflow
32
- # past the last tick of the pattern.
33
- def sample_length_with_overflow(tick_sample_length)
34
- @tracks.keys.collect {|track_name| @tracks[track_name].sample_length_with_overflow(tick_sample_length) }.max || 0
35
- end
36
-
37
- def tick_count
28
+ def step_count
38
29
  return @tracks.values.collect {|track| track.rhythm.length }.max || 0
39
30
  end
40
-
41
- def sample_data(tick_sample_length, num_channels, num_tracks_in_song, incoming_overflow)
42
- primary_sample_data, overflow_sample_data = generate_main_sample_data(tick_sample_length, num_channels)
43
- primary_sample_data, overflow_sample_data = handle_incoming_overflow(tick_sample_length,
44
- num_channels,
45
- incoming_overflow,
46
- primary_sample_data,
47
- overflow_sample_data)
48
- primary_sample_data = mixdown_sample_data(num_channels, num_tracks_in_song, primary_sample_data)
49
-
50
- return {:primary => primary_sample_data, :overflow => overflow_sample_data}
51
- end
52
31
 
53
32
  # Returns whether or not this pattern has the same number of tracks as other_pattern, and that
54
33
  # each of the tracks has the same name and rhythm. Ordering of tracks does not matter; will
@@ -86,93 +65,17 @@ class Pattern
86
65
 
87
66
  private
88
67
 
89
- def generate_main_sample_data(tick_sample_length, num_channels)
90
- track_names = @tracks.keys
91
- primary_sample_data = []
92
- overflow_sample_data = {}
93
- actual_sample_length = sample_length(tick_sample_length)
94
-
95
- if @intermediate_cache == nil
96
- track_names.each do |track_name|
97
- temp = @tracks[track_name].sample_data(tick_sample_length)
98
-
99
- if primary_sample_data == []
100
- primary_sample_data = temp[:primary]
101
- overflow_sample_data[track_name] = temp[:overflow]
102
- else
103
- track_samples = temp[:primary]
104
- if num_channels == 1
105
- track_samples.length.times {|i| primary_sample_data[i] += track_samples[i] }
106
- else
107
- track_samples.length.times do |i|
108
- primary_sample_data[i] = [primary_sample_data[i][0] + track_samples[i][0],
109
- primary_sample_data[i][1] + track_samples[i][1]]
110
- end
111
- end
112
-
113
- overflow_sample_data[track_name] = temp[:overflow]
114
- end
115
- end
116
-
117
- @intermediate_cache = {:primary => primary_sample_data.dup, :overflow => overflow_sample_data.dup}
118
- else
119
- primary_sample_data = @intermediate_cache[:primary].dup
120
- overflow_sample_data = @intermediate_cache[:overflow].dup
68
+ # Returns a unique track name that is not already in use by a track in
69
+ # this pattern. Used to help support having multiple tracks with the same
70
+ # sample in a track.
71
+ def unique_track_name(name)
72
+ i = 2
73
+ name_key = name
74
+ while @tracks.has_key? name_key
75
+ name_key = "#{name}#{i.to_s}"
76
+ i += 1
121
77
  end
122
-
123
- return primary_sample_data, overflow_sample_data
124
- end
125
-
126
- def handle_incoming_overflow(tick_sample_length, num_channels, incoming_overflow, primary_sample_data, overflow_sample_data)
127
- track_names = @tracks.keys
128
-
129
- # Add overflow from previous pattern
130
- incoming_overflow.keys.each do |track_name|
131
- num_incoming_overflow_samples = incoming_overflow[track_name].length
132
78
 
133
- if num_incoming_overflow_samples > 0
134
- if track_names.member?(track_name)
135
- # TODO: Does this handle situations where track has a .... rhythm and overflow is
136
- # longer than track length?
137
-
138
- intro_length = @tracks[track_name].intro_sample_length(tick_sample_length)
139
- if num_incoming_overflow_samples > intro_length
140
- num_incoming_overflow_samples = intro_length
141
- end
142
- else
143
- # If incoming overflow for track is longer than the pattern length, only add the first part of
144
- # the overflow to the pattern, and add the remainder to overflow_sample_data so that it gets
145
- # handled by the next pattern to be generated.
146
- if num_incoming_overflow_samples > primary_sample_data.length
147
- overflow_sample_data[track_name] = (incoming_overflow[track_name])[primary_sample_data.length...num_incoming_overflow_samples]
148
- num_incoming_overflow_samples = primary_sample_data.length
149
- end
150
- end
151
-
152
- if num_channels == 1
153
- num_incoming_overflow_samples.times {|i| primary_sample_data[i] += incoming_overflow[track_name][i]}
154
- else
155
- num_incoming_overflow_samples.times do |i|
156
- primary_sample_data[i] = [primary_sample_data[i][0] + incoming_overflow[track_name][i][0],
157
- primary_sample_data[i][1] + incoming_overflow[track_name][i][1]]
158
- end
159
- end
160
- end
161
- end
162
-
163
- return primary_sample_data, overflow_sample_data
164
- end
165
-
166
- def mixdown_sample_data(num_channels, num_tracks_in_song, primary_sample_data)
167
- # Mix down the pattern's tracks into one single track
168
- if num_tracks_in_song > 1
169
- if num_channels == 1
170
- primary_sample_data = primary_sample_data.map {|sample| sample / num_tracks_in_song }
171
- else
172
- primary_sample_data = primary_sample_data.map {|sample| [sample[0] / num_tracks_in_song, sample[1] / num_tracks_in_song]}
173
- end
174
- end
175
-
176
- return primary_sample_data
79
+ return name_key
177
80
  end
178
- end
81
+ end
@@ -0,0 +1,111 @@
1
+ class InvalidFlowError < RuntimeError; end
2
+
3
+ # This class is used for an experimental feature that allows specifying repeats inside of
4
+ # individual patterns, instead of the song flow. This feature is currently disabled, so for
5
+ # the time being this class is dead code.
6
+ #
7
+ # TODO: The expand_pattern method in this class should probably be moved to the Pattern class.
8
+ # This class would then go away.
9
+ class PatternExpander
10
+ BARLINE = "|"
11
+ TICK = "-"
12
+ REPEAT_FRAME_REGEX = /:[-]*:[0-9]*/
13
+ NUMBER_REGEX = /[0-9]+/
14
+
15
+ # TODO: What should happen if flow is longer than pattern?
16
+ # Either ignore extra flow, or add trailing .... to each track to match up?
17
+ def self.expand_pattern(flow, pattern)
18
+ unless self.valid_flow? flow
19
+ raise InvalidFlowError, "Invalid flow"
20
+ end
21
+
22
+ flow = flow.delete(BARLINE)
23
+
24
+ # Count number of :
25
+ # If odd, then there's an implicit : at the beginning of the pattern.
26
+ # TODO: What if the first character in the flow is already :
27
+ # That means repeat the first step twice, right?
28
+ number_of_colons = flow.scan(/:/).length
29
+ if number_of_colons % 2 == 1
30
+ # TODO: What if flow[0] is not '-'
31
+ flow[0] = ":" # Make the implicit : at the beginning explicit
32
+ end
33
+
34
+ repeat_frames = parse_flow_for_repeat_frames(flow)
35
+
36
+ repeat_frames.reverse.each do |frame|
37
+ pattern.tracks.each do |name, track|
38
+ range = frame[:range]
39
+
40
+ # WARNING: Don't change the three lines below to:
41
+ # track.rhythm[range] = whatever
42
+ # When changing the rhythm like this, rhythm=() won't be called,
43
+ # and Track.beats won't be updated as a result.
44
+ new_rhythm = track.rhythm
45
+ new_rhythm[range] = new_rhythm[range] * frame[:repeats]
46
+ track.rhythm = new_rhythm
47
+ end
48
+ end
49
+
50
+ return pattern
51
+ end
52
+
53
+ # TODO: Return more specific info on why flow isn't valid
54
+ def self.valid_flow?(flow)
55
+ flow = flow.delete(BARLINE)
56
+ flow = flow.delete(TICK)
57
+
58
+ # If flow contains any characters other than : and [0-9], it's invalid.
59
+ if flow.match(/[^:0-9]/) != nil
60
+ return false
61
+ end
62
+
63
+ # If flow contains nothing but :, it's always valid.
64
+ if flow == ":" * flow.length
65
+ return true
66
+ end
67
+
68
+ # If flow DOESN'T contain a :, it's not valid.
69
+ if flow.match(/:/) == nil
70
+ return false
71
+ end
72
+
73
+ segments = flow.split(/[0-9]+/)
74
+
75
+ # Ignore first segment
76
+ segments[1...segments.length].each do |segment|
77
+ if segment.length % 2 == 1
78
+ return false
79
+ end
80
+ end
81
+
82
+ return true
83
+ end
84
+
85
+ private
86
+
87
+ def self.parse_flow_for_repeat_frames(flow)
88
+ repeat_frames = []
89
+ lower_bound = 0
90
+ frame_start_index = flow[lower_bound...flow.length] =~ REPEAT_FRAME_REGEX
91
+ while frame_start_index != nil do
92
+ str = flow[lower_bound...flow.length].match(REPEAT_FRAME_REGEX).to_s
93
+
94
+ range_start = lower_bound + frame_start_index
95
+ range_end = range_start + str.length - 1
96
+
97
+ num_repeats = str.match(NUMBER_REGEX).to_s
98
+ num_repeats = (num_repeats == "") ? 2 : num_repeats.to_i
99
+
100
+ repeat_frame = {}
101
+ repeat_frame[:range] = range_start..range_end
102
+ repeat_frame[:repeats] = num_repeats
103
+ repeat_frames << repeat_frame
104
+
105
+ lower_bound += frame_start_index + str.length
106
+ frame_start_index = flow[lower_bound...flow.length] =~ REPEAT_FRAME_REGEX
107
+ end
108
+
109
+ return repeat_frames
110
+ end
111
+ end