beats 1.2.0 → 1.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.
- data/LICENSE +1 -1
- data/README.markdown +28 -10
- data/bin/beats +9 -7
- data/lib/audioengine.rb +172 -0
- data/lib/audioutils.rb +73 -0
- data/lib/beats.rb +14 -15
- data/lib/beatswavefile.rb +17 -37
- data/lib/kit.rb +148 -71
- data/lib/pattern.rb +20 -117
- data/lib/patternexpander.rb +111 -0
- data/lib/song.rb +78 -132
- data/lib/songoptimizer.rb +29 -33
- data/lib/songparser.rb +70 -45
- data/lib/track.rb +11 -82
- data/test/audioengine_test.rb +261 -0
- data/test/audioutils_test.rb +45 -0
- data/test/fixtures/expected_output/example_split_mono_16-hh_closed.wav +0 -0
- data/test/{examples/split-agogo_high.wav → fixtures/expected_output/example_split_mono_16-hh_closed2.wav} +0 -0
- data/test/fixtures/expected_output/example_split_mono_8-hh_closed.wav +0 -0
- data/test/{examples/split-tom4.wav → fixtures/expected_output/example_split_mono_8-hh_closed2.wav} +0 -0
- data/test/fixtures/expected_output/example_split_stereo_16-hh_closed.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_16-hh_closed2.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_8-hh_closed.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_8-hh_closed2.wav +0 -0
- data/test/fixtures/invalid/{bad_structure.txt → bad_flow.txt} +2 -2
- data/test/fixtures/invalid/bad_repeat_count.txt +1 -1
- data/test/fixtures/invalid/bad_rhythm.txt +1 -1
- data/test/fixtures/invalid/bad_tempo.txt +1 -1
- data/test/fixtures/invalid/{no_structure.txt → no_flow.txt} +1 -1
- data/test/fixtures/invalid/pattern_with_no_tracks.txt +1 -1
- data/test/fixtures/invalid/sound_in_kit_not_found.txt +1 -1
- data/test/fixtures/invalid/sound_in_kit_wrong_format.txt +10 -0
- data/test/fixtures/invalid/sound_in_track_not_found.txt +1 -1
- data/test/fixtures/invalid/sound_in_track_wrong_format.txt +8 -0
- data/test/fixtures/invalid/template.txt +1 -1
- data/test/fixtures/valid/example_mono_16.txt +5 -3
- data/test/fixtures/valid/example_mono_8.txt +5 -3
- data/test/fixtures/valid/example_no_kit.txt +1 -1
- data/test/fixtures/valid/example_stereo_16.txt +7 -4
- data/test/fixtures/valid/example_stereo_8.txt +5 -3
- data/test/fixtures/valid/example_with_empty_track.txt +1 -1
- data/test/fixtures/valid/example_with_kit.txt +1 -1
- data/test/fixtures/valid/multiple_tracks_same_sound.txt +33 -0
- data/test/fixtures/valid/no_tempo.txt +1 -1
- data/test/fixtures/valid/optimize_pattern_collision.txt +28 -0
- data/test/fixtures/valid/pattern_with_overflow.txt +1 -1
- data/test/fixtures/valid/repeats_not_specified.txt +2 -2
- data/test/fixtures/valid/with_structure.txt +10 -0
- data/test/fixtures/yaml/song_yaml.txt +5 -5
- data/test/includes.rb +4 -2
- data/test/integration.rb +3 -3
- data/test/kit_test.rb +136 -109
- data/test/pattern_test.rb +31 -131
- data/test/patternexpander_test.rb +142 -0
- data/test/song_test.rb +104 -102
- data/test/songoptimizer_test.rb +52 -38
- data/test/songparser_test.rb +79 -46
- data/test/sounds/composite_snare_mono_8_tom3_mono_16_mono_16.wav +0 -0
- data/test/sounds/composite_snare_mono_8_tom3_mono_8_mono_16.wav +0 -0
- data/test/sounds/composite_snare_stereo_16_tom3_mono_16_stereo_16.wav +0 -0
- data/test/sounds/composite_snare_stereo_8_tom3_mono_16_stereo_16.wav +0 -0
- data/test/track_test.rb +30 -185
- metadata +56 -24
- data/lib/songsplitter.rb +0 -38
- data/test/examples/combined.wav +0 -0
- data/test/examples/split-bass.wav +0 -0
- data/test/examples/split-hh_closed.wav +0 -0
- data/test/examples/split-snare.wav +0 -0
- 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
|
2
|
+
class SoundFileNotFoundError < RuntimeError; end
|
3
3
|
|
4
|
-
#
|
5
|
-
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
13
|
-
#
|
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
|
-
#
|
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
|
-
#
|
22
|
-
#
|
23
|
-
#
|
24
|
-
#
|
25
|
-
#
|
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
|
-
|
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
|
-
@
|
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
|
-
#
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
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
|
-
|
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
|
76
|
-
|
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
|
-
|
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
|
-
|
86
|
-
|
87
|
-
|
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
|
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
|
data/lib/pattern.rb
CHANGED
@@ -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,
|
9
|
-
|
10
|
-
|
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 =
|
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
|
-
|
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
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
|
97
|
-
|
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
|
-
|
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
|