beats 1.2.4 → 1.2.5
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE +1 -1
- data/README.markdown +24 -4
- data/Rakefile +1 -1
- data/bin/beats +10 -15
- data/ext/mkrf_conf.rb +28 -0
- data/lib/beats.rb +12 -76
- data/lib/beats/audioengine.rb +163 -0
- data/lib/beats/audioutils.rb +74 -0
- data/lib/beats/beatsrunner.rb +76 -0
- data/lib/beats/kit.rb +187 -0
- data/lib/beats/pattern.rb +88 -0
- data/lib/beats/song.rb +162 -0
- data/lib/beats/songoptimizer.rb +162 -0
- data/lib/beats/songparser.rb +217 -0
- data/lib/beats/track.rb +57 -0
- data/lib/wavefile/cachingwriter.rb +1 -1
- data/test/audioengine_test.rb +5 -5
- data/test/cachingwriter_test.rb +1 -1
- data/test/fixtures/valid/foo.txt +18 -0
- data/test/includes.rb +2 -9
- data/test/integration_test.rb +20 -20
- data/test/kit_test.rb +28 -28
- data/test/pattern_test.rb +13 -13
- data/test/song_test.rb +23 -23
- data/test/songoptimizer_test.rb +25 -25
- data/test/songparser_test.rb +11 -11
- data/test/sounds/bass.wav +0 -0
- data/test/sounds/bass2.wav +0 -0
- data/test/track_test.rb +12 -12
- metadata +27 -22
- data/lib/audioengine.rb +0 -161
- data/lib/audioutils.rb +0 -73
- data/lib/kit.rb +0 -185
- data/lib/pattern.rb +0 -86
- data/lib/song.rb +0 -160
- data/lib/songoptimizer.rb +0 -160
- data/lib/songparser.rb +0 -216
- data/lib/track.rb +0 -55
@@ -0,0 +1,76 @@
|
|
1
|
+
module Beats
|
2
|
+
class BeatsRunner
|
3
|
+
# Each pattern in the song will be split up into sub patterns that have at most this many steps.
|
4
|
+
# In general, audio for several shorter patterns can be generated more quickly than for one long
|
5
|
+
# pattern, and can also be cached more effectively.
|
6
|
+
OPTIMIZED_PATTERN_LENGTH = 4
|
7
|
+
|
8
|
+
def initialize(input_file_name, output_file_name, options)
|
9
|
+
@input_file_name = input_file_name
|
10
|
+
|
11
|
+
if output_file_name.nil?
|
12
|
+
output_file_name = File.basename(input_file_name, File.extname(input_file_name)) + ".wav"
|
13
|
+
end
|
14
|
+
@output_file_name = output_file_name
|
15
|
+
|
16
|
+
@options = options
|
17
|
+
end
|
18
|
+
|
19
|
+
def run
|
20
|
+
base_path = @options[:base_path] || File.dirname(@input_file_name)
|
21
|
+
song, kit = SongParser.new().parse(base_path, File.read(@input_file_name))
|
22
|
+
|
23
|
+
song = normalize_for_pattern_option(song)
|
24
|
+
songs_to_generate = normalize_for_split_option(song)
|
25
|
+
|
26
|
+
song_optimizer = SongOptimizer.new()
|
27
|
+
durations = songs_to_generate.collect do |output_file_name, song|
|
28
|
+
song = song_optimizer.optimize(song, OPTIMIZED_PATTERN_LENGTH)
|
29
|
+
AudioEngine.new(song, kit).write_to_file(output_file_name)
|
30
|
+
end
|
31
|
+
|
32
|
+
{:duration => durations.last}
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
# If the -p option is used, transform the song into one whose flow consists of
|
38
|
+
# playing that single pattern once.
|
39
|
+
def normalize_for_pattern_option(song)
|
40
|
+
unless @options[:pattern].nil?
|
41
|
+
pattern_name = @options[:pattern].downcase.to_sym
|
42
|
+
|
43
|
+
unless song.patterns.has_key?(pattern_name)
|
44
|
+
raise StandardError, "The song does not include a pattern called #{pattern_name}"
|
45
|
+
end
|
46
|
+
|
47
|
+
song.flow = [pattern_name]
|
48
|
+
song.remove_unused_patterns()
|
49
|
+
end
|
50
|
+
|
51
|
+
song
|
52
|
+
end
|
53
|
+
|
54
|
+
# Returns a hash of file name => song object for each song that should go through the audio engine
|
55
|
+
def normalize_for_split_option(song)
|
56
|
+
songs_to_generate = {}
|
57
|
+
|
58
|
+
if @options[:split]
|
59
|
+
split_songs = song.split()
|
60
|
+
split_songs.each do |track_name, split_song|
|
61
|
+
# TODO: Move building the output file name into its own method?
|
62
|
+
extension = File.extname(@output_file_name)
|
63
|
+
file_name = File.dirname(@output_file_name) + "/" +
|
64
|
+
File.basename(@output_file_name, extension) + "-" + File.basename(track_name, extension) +
|
65
|
+
extension
|
66
|
+
|
67
|
+
songs_to_generate[file_name] = split_song
|
68
|
+
end
|
69
|
+
else
|
70
|
+
songs_to_generate[@output_file_name] = song
|
71
|
+
end
|
72
|
+
|
73
|
+
songs_to_generate
|
74
|
+
end
|
75
|
+
end
|
76
|
+
end
|
data/lib/beats/kit.rb
ADDED
@@ -0,0 +1,187 @@
|
|
1
|
+
module Beats
|
2
|
+
# Raised when trying to load a sound file which can't be found at the path specified
|
3
|
+
class SoundFileNotFoundError < RuntimeError; end
|
4
|
+
|
5
|
+
# Raised when trying to load a sound file which either isn't actually a sound file, or
|
6
|
+
# is in an unsupported format.
|
7
|
+
class InvalidSoundFormatError < RuntimeError; end
|
8
|
+
|
9
|
+
|
10
|
+
# This class provides a repository for the sounds used in a song. Most usefully, it
|
11
|
+
# also handles converting the sounds to a common format. For example, if a song requires
|
12
|
+
# a sound that is mono/8-bit, another that is stereo/8-bit, and another that is
|
13
|
+
# stereo/16-bit, they have to be converted to a common format before they can be used
|
14
|
+
# together. Kit handles this conversion; all sounds retrieved using
|
15
|
+
# get_sample_data() will be in a common format.
|
16
|
+
#
|
17
|
+
# Sounds can only be added at initialization. During initialization, the sample data
|
18
|
+
# for each sound is loaded into memory, and converted to the common format if necessary.
|
19
|
+
# This format is:
|
20
|
+
#
|
21
|
+
# Bits per sample: 16
|
22
|
+
# Sample rate: 44100
|
23
|
+
# Channels: Stereo, unless all of the kit sounds are mono.
|
24
|
+
#
|
25
|
+
# For example if the kit has these sounds:
|
26
|
+
#
|
27
|
+
# my_sound_1.wav: mono, 16-bit
|
28
|
+
# my_sound_2.wav: stereo, 8-bit
|
29
|
+
# my_sound_3.wav: mono, 8-bit
|
30
|
+
#
|
31
|
+
# they will all be converted to stereo/16-bit during initialization.
|
32
|
+
class Kit
|
33
|
+
def initialize(base_path, kit_items)
|
34
|
+
@base_path = base_path
|
35
|
+
@label_mappings = {}
|
36
|
+
@sound_bank = {}
|
37
|
+
@num_channels = 1
|
38
|
+
@bits_per_sample = 16 # Only use 16-bit files as output. Supporting 8-bit output
|
39
|
+
# means extra complication for no real gain (I'm skeptical
|
40
|
+
# anyone would explicitly want 8-bit output instead of 16-bit).
|
41
|
+
|
42
|
+
load_sounds(base_path, kit_items)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Returns the sample data for a sound contained in the Kit. If the all sounds in the
|
46
|
+
# kit are mono, then this will be a flat Array of Fixnums between -32768 and 32767.
|
47
|
+
# Otherwise, this will be an Array of Fixnums pairs between -32768 and 32767.
|
48
|
+
#
|
49
|
+
# label - The name of the sound to get sample data for. If the sound was defined in
|
50
|
+
# the Kit section of a song file, this will generally be a descriptive label
|
51
|
+
# such as "bass" or "snare". If defined in a track but not the kit, it will
|
52
|
+
# generally be a file name such as "my_sounds/hihat/hi_hat.wav".
|
53
|
+
#
|
54
|
+
# Examples
|
55
|
+
#
|
56
|
+
# # If @num_channels is 1, a flat Array of Fixnums:
|
57
|
+
# get_sample_data("bass")
|
58
|
+
# # => [154, 7023, 8132, 2622, -132, 34, ..., -6702]
|
59
|
+
#
|
60
|
+
# # If @num_channels is 2, a Array of Fixnums pairs:
|
61
|
+
# get_sample_data("snare")
|
62
|
+
# # => [[57, 1265], [-452, 10543], [-2531, 12643], [-6372, 11653], ..., [5482, 25673]]
|
63
|
+
#
|
64
|
+
# Returns the sample data Array for the sound bound to label.
|
65
|
+
def get_sample_data(label)
|
66
|
+
if label == "placeholder"
|
67
|
+
return []
|
68
|
+
end
|
69
|
+
|
70
|
+
sample_data = @sound_bank[label]
|
71
|
+
|
72
|
+
if sample_data.nil?
|
73
|
+
# TODO: Should we really throw an exception here rather than just returning nil?
|
74
|
+
raise StandardError, "Kit doesn't contain sound '#{label}'."
|
75
|
+
else
|
76
|
+
return sample_data
|
77
|
+
end
|
78
|
+
end
|
79
|
+
|
80
|
+
def scale!(scale_factor)
|
81
|
+
@sound_bank.each do |label, sample_array|
|
82
|
+
@sound_bank[label] = AudioUtils.scale(sample_array, @num_channels, scale_factor)
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
# Returns a YAML representation of the Kit. Produces nicer looking output than the default version
|
87
|
+
# of to_yaml().
|
88
|
+
#
|
89
|
+
# indent_space_count - The number of spaces to indent each line in the output (default: 0).
|
90
|
+
#
|
91
|
+
# Returns a String representation of the Kit in YAML format.
|
92
|
+
def to_yaml(indent_space_count = 0)
|
93
|
+
yaml = ""
|
94
|
+
longest_label_mapping_length =
|
95
|
+
@label_mappings.keys.inject(0) do |max_length, name|
|
96
|
+
(name.to_s.length > max_length) ? name.to_s.length : max_length
|
97
|
+
end
|
98
|
+
|
99
|
+
if @label_mappings.length > 0
|
100
|
+
yaml += " " * indent_space_count + "Kit:\n"
|
101
|
+
ljust_amount = longest_label_mapping_length + 1 # The +1 is for the trailing ":"
|
102
|
+
@label_mappings.sort.each do |label, path|
|
103
|
+
yaml += " " * indent_space_count + " - #{(label + ":").ljust(ljust_amount)} #{path}\n"
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
yaml
|
108
|
+
end
|
109
|
+
|
110
|
+
attr_reader :base_path, :label_mappings, :bits_per_sample, :num_channels
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def load_sounds(base_path, kit_items)
|
115
|
+
# Set label mappings
|
116
|
+
kit_items.each do |label, sound_file_names|
|
117
|
+
if sound_file_names.class == Array
|
118
|
+
raise StandardError, "Composite sounds aren't allowed (yet...)"
|
119
|
+
end
|
120
|
+
|
121
|
+
unless label == sound_file_names
|
122
|
+
@label_mappings[label] = sound_file_names
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
kit_items = make_file_names_absolute(kit_items)
|
127
|
+
sound_buffers = load_raw_sounds(kit_items)
|
128
|
+
|
129
|
+
canonical_format = WaveFile::Format.new(@num_channels, @bits_per_sample, 44100)
|
130
|
+
|
131
|
+
# Convert each sound to a common format
|
132
|
+
sound_buffers.each {|file_name, buffer| sound_buffers[file_name] = buffer.convert(canonical_format) }
|
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, sound_buffers)
|
137
|
+
end
|
138
|
+
end
|
139
|
+
|
140
|
+
# Converts relative paths into absolute paths. Note that this will also handle
|
141
|
+
# expanding ~ on platforms that support that.
|
142
|
+
def make_file_names_absolute(kit_items)
|
143
|
+
kit_items.each do |label, sound_file_names|
|
144
|
+
unless sound_file_names.class == Array
|
145
|
+
sound_file_names = [sound_file_names]
|
146
|
+
end
|
147
|
+
|
148
|
+
sound_file_names.map! {|sound_file_name| File.expand_path(sound_file_name, base_path) }
|
149
|
+
kit_items[label] = sound_file_names
|
150
|
+
end
|
151
|
+
|
152
|
+
kit_items
|
153
|
+
end
|
154
|
+
|
155
|
+
# Load all sound files, bailing if any are invalid
|
156
|
+
def load_raw_sounds(kit_items)
|
157
|
+
raw_sounds = {}
|
158
|
+
kit_items.values.flatten.each do |sound_file_name|
|
159
|
+
begin
|
160
|
+
info = WaveFile::Reader.info(sound_file_name)
|
161
|
+
WaveFile::Reader.new(sound_file_name).each_buffer(info.sample_frame_count) do |buffer|
|
162
|
+
raw_sounds[sound_file_name] = buffer
|
163
|
+
@num_channels = [@num_channels, buffer.channels].max
|
164
|
+
end
|
165
|
+
rescue Errno::ENOENT
|
166
|
+
raise SoundFileNotFoundError, "Sound file #{sound_file_name} not found."
|
167
|
+
rescue StandardError
|
168
|
+
raise InvalidSoundFormatError, "Sound file #{sound_file_name} is either not a sound file, " +
|
169
|
+
"or is in an unsupported format. BEATS can handle 8, 16, 24, or 32-bit PCM *.wav files."
|
170
|
+
end
|
171
|
+
end
|
172
|
+
|
173
|
+
raw_sounds
|
174
|
+
end
|
175
|
+
|
176
|
+
def mixdown(sound_file_names, raw_sounds)
|
177
|
+
sample_arrays = []
|
178
|
+
sound_file_names.each do |sound_file_name|
|
179
|
+
sample_arrays << raw_sounds[sound_file_name].samples
|
180
|
+
end
|
181
|
+
|
182
|
+
composited_sample_data = AudioUtils.composite(sample_arrays, @num_channels)
|
183
|
+
|
184
|
+
AudioUtils.scale(composited_sample_data, @num_channels, sound_file_names.length)
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
module Beats
|
2
|
+
# Domain object which models one or more Tracks playing a part of the song at the same time.
|
3
|
+
# For example, a bass drum, snare drum, and hi-hat track playing the song's chorus.
|
4
|
+
#
|
5
|
+
# This object is like sheet music; the AudioEngine is responsible creating actual
|
6
|
+
# audio data for a Pattern (with the help of a Kit).
|
7
|
+
class Pattern
|
8
|
+
FLOW_TRACK_NAME = "flow"
|
9
|
+
|
10
|
+
def initialize(name)
|
11
|
+
@name = name
|
12
|
+
@tracks = {}
|
13
|
+
end
|
14
|
+
|
15
|
+
# Adds a new track to the pattern.
|
16
|
+
def track(name, rhythm)
|
17
|
+
track_key = unique_track_name(name)
|
18
|
+
new_track = Track.new(name, rhythm)
|
19
|
+
@tracks[track_key] = new_track
|
20
|
+
|
21
|
+
# If the new track is longer than any of the previously added tracks,
|
22
|
+
# pad the other tracks with trailing . to make them all the same length.
|
23
|
+
# Necessary to prevent incorrect overflow calculations for tracks.
|
24
|
+
longest_track_length = step_count()
|
25
|
+
@tracks.values.each do |track|
|
26
|
+
if track.rhythm.length < longest_track_length
|
27
|
+
track.rhythm += "." * (longest_track_length - track.rhythm.length)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
new_track
|
32
|
+
end
|
33
|
+
|
34
|
+
def step_count
|
35
|
+
@tracks.values.collect {|track| track.rhythm.length }.max || 0
|
36
|
+
end
|
37
|
+
|
38
|
+
# Returns whether or not this pattern has the same number of tracks as other_pattern, and that
|
39
|
+
# each of the tracks has the same name and rhythm. Ordering of tracks does not matter; will
|
40
|
+
# return true if the two patterns have the same tracks but in a different ordering.
|
41
|
+
def same_tracks_as?(other_pattern)
|
42
|
+
@tracks.keys.each do |track_name|
|
43
|
+
other_pattern_track = other_pattern.tracks[track_name]
|
44
|
+
if other_pattern_track.nil? || @tracks[track_name].rhythm != other_pattern_track.rhythm
|
45
|
+
return false
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
@tracks.length == other_pattern.tracks.length
|
50
|
+
end
|
51
|
+
|
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
|
71
|
+
|
72
|
+
private
|
73
|
+
|
74
|
+
# Returns a unique track name that is not already in use by a track in
|
75
|
+
# this pattern. Used to help support having multiple tracks with the same
|
76
|
+
# sample in a track.
|
77
|
+
def unique_track_name(name)
|
78
|
+
i = 2
|
79
|
+
name_key = name
|
80
|
+
while @tracks.has_key? name_key
|
81
|
+
name_key = "#{name}#{i.to_s}"
|
82
|
+
i += 1
|
83
|
+
end
|
84
|
+
|
85
|
+
name_key
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
data/lib/beats/song.rb
ADDED
@@ -0,0 +1,162 @@
|
|
1
|
+
module Beats
|
2
|
+
class InvalidTempoError < RuntimeError; end
|
3
|
+
|
4
|
+
|
5
|
+
# Domain object which models the 'sheet music' for a full song. Models the Patterns
|
6
|
+
# that should be played, in which order (i.e. the flow), and at which tempo.
|
7
|
+
#
|
8
|
+
# This is the top-level model object that is used by the AudioEngine to produce
|
9
|
+
# actual audio data. A Song tells the AudioEngine what sounds to trigger and when.
|
10
|
+
# A Kit provides the sample data for each of these sounds. With a Song and a Kit
|
11
|
+
# the AudioEngine can produce the audio data that is saved to disk.
|
12
|
+
class Song
|
13
|
+
DEFAULT_TEMPO = 120
|
14
|
+
|
15
|
+
def initialize
|
16
|
+
self.tempo = DEFAULT_TEMPO
|
17
|
+
@patterns = {}
|
18
|
+
@flow = []
|
19
|
+
end
|
20
|
+
|
21
|
+
# Adds a new pattern to the song, with the specified name.
|
22
|
+
def pattern(name)
|
23
|
+
@patterns[name] = Pattern.new(name)
|
24
|
+
@patterns[name]
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
# The number of tracks that the pattern with the greatest number of tracks has.
|
29
|
+
# TODO: Is it a problem that an optimized song can have a different total_tracks() value than
|
30
|
+
# the original? Or is that actually a good thing?
|
31
|
+
# TODO: Investigate replacing this with a method max_sounds_playing_at_once() or something
|
32
|
+
# like that. Would look each pattern along with it's incoming overflow.
|
33
|
+
def total_tracks
|
34
|
+
@patterns.keys.collect {|pattern_name| @patterns[pattern_name].tracks.length }.max || 0
|
35
|
+
end
|
36
|
+
|
37
|
+
# The unique track names used in each of the song's patterns. Sorted in alphabetical order.
|
38
|
+
# For example calling this method for this song:
|
39
|
+
#
|
40
|
+
# Verse:
|
41
|
+
# - bass: X...
|
42
|
+
# - snare: ..X.
|
43
|
+
#
|
44
|
+
# Chorus:
|
45
|
+
# - bass: X.X.
|
46
|
+
# - snare: X.X.
|
47
|
+
# - hihat: XXXX
|
48
|
+
#
|
49
|
+
# Will return: ["bass", "hihat", "snare"]
|
50
|
+
def track_names
|
51
|
+
@patterns.values.inject([]) {|track_names, pattern| track_names | pattern.tracks.keys }.sort
|
52
|
+
end
|
53
|
+
|
54
|
+
def tempo=(new_tempo)
|
55
|
+
unless new_tempo.class == Fixnum && new_tempo > 0
|
56
|
+
raise InvalidTempoError, "Invalid tempo: '#{new_tempo}'. Tempo must be a number greater than 0."
|
57
|
+
end
|
58
|
+
|
59
|
+
@tempo = new_tempo
|
60
|
+
end
|
61
|
+
|
62
|
+
# Returns a new Song that is identical but with no patterns or flow.
|
63
|
+
def copy_ignoring_patterns_and_flow
|
64
|
+
copy = Song.new()
|
65
|
+
copy.tempo = @tempo
|
66
|
+
|
67
|
+
copy
|
68
|
+
end
|
69
|
+
|
70
|
+
# Splits a Song object into multiple Song objects, where each new
|
71
|
+
# Song only has 1 track. For example, if a Song has 5 tracks, this will return
|
72
|
+
# a hash of 5 songs, each with one of the original Song's tracks.
|
73
|
+
def split
|
74
|
+
split_songs = {}
|
75
|
+
track_names = track_names()
|
76
|
+
|
77
|
+
track_names.each do |track_name|
|
78
|
+
new_song = copy_ignoring_patterns_and_flow()
|
79
|
+
|
80
|
+
@patterns.each do |name, original_pattern|
|
81
|
+
new_pattern = new_song.pattern(name)
|
82
|
+
|
83
|
+
if original_pattern.tracks.has_key?(track_name)
|
84
|
+
original_track = original_pattern.tracks[track_name]
|
85
|
+
new_pattern.track(original_track.name, original_track.rhythm)
|
86
|
+
else
|
87
|
+
new_pattern.track(track_name, "." * original_pattern.step_count)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
|
91
|
+
new_song.flow = @flow
|
92
|
+
|
93
|
+
split_songs[track_name] = new_song
|
94
|
+
end
|
95
|
+
|
96
|
+
split_songs
|
97
|
+
end
|
98
|
+
|
99
|
+
# Removes any patterns that aren't referenced in the flow.
|
100
|
+
def remove_unused_patterns
|
101
|
+
# Using reject() here because for some reason select() returns an Array not a Hash.
|
102
|
+
@patterns.reject! {|k, pattern| !@flow.member?(pattern.name) }
|
103
|
+
end
|
104
|
+
|
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
|
+
attr_reader :patterns, :tempo
|
123
|
+
attr_accessor :flow
|
124
|
+
|
125
|
+
private
|
126
|
+
|
127
|
+
def longest_length_in_array(arr)
|
128
|
+
arr.inject(0) {|max_length, name| [name.to_s.length, max_length].max }
|
129
|
+
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
|
+
end
|
162
|
+
end
|