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