beats 1.1.0 → 1.2.0
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/README.markdown +29 -4
- data/bin/beats +36 -71
- data/lib/beats.rb +57 -0
- data/lib/beatswavefile.rb +93 -0
- data/lib/kit.rb +67 -11
- data/lib/pattern.rb +131 -86
- data/lib/song.rb +145 -114
- data/lib/songoptimizer.rb +154 -0
- data/lib/songparser.rb +40 -28
- data/lib/songsplitter.rb +38 -0
- data/lib/track.rb +33 -31
- data/lib/wavefile.rb +475 -0
- data/test/examples/combined.wav +0 -0
- data/test/examples/split-agogo_high.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/test/examples/split-tom4.wav +0 -0
- data/test/fixtures/expected_output/example_combined_mono_16.wav +0 -0
- data/test/fixtures/expected_output/example_combined_mono_8.wav +0 -0
- data/test/fixtures/expected_output/example_combined_stereo_16.wav +0 -0
- data/test/fixtures/expected_output/example_combined_stereo_8.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_16-agogo.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_16-bass.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_16-hh_closed.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_16-snare.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_16-tom2_mono_16.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_16-tom4_mono_16.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_8-agogo.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_8-bass.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_8-hh_closed.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_8-snare.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_8-tom2_mono_8.wav +0 -0
- data/test/fixtures/expected_output/example_split_mono_8-tom4_mono_8.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_16-agogo.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_16-bass.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-snare.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_16-tom2_stereo_16.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_16-tom4_stereo_16.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_8-agogo.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_8-bass.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-snare.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_8-tom2_stereo_8.wav +0 -0
- data/test/fixtures/expected_output/example_split_stereo_8-tom4_stereo_8.wav +0 -0
- data/test/fixtures/invalid/bad_repeat_count.txt +8 -0
- data/test/fixtures/invalid/bad_rhythm.txt +9 -0
- data/test/fixtures/invalid/bad_structure.txt +9 -0
- data/test/fixtures/invalid/bad_tempo.txt +8 -0
- data/test/fixtures/invalid/no_header.txt +3 -0
- data/test/fixtures/invalid/no_structure.txt +6 -0
- data/test/fixtures/invalid/pattern_with_no_tracks.txt +12 -0
- data/test/fixtures/invalid/sound_in_kit_not_found.txt +10 -0
- data/test/fixtures/invalid/sound_in_track_not_found.txt +8 -0
- data/test/fixtures/invalid/template.txt +31 -0
- data/test/fixtures/valid/example_mono_16.txt +28 -0
- data/test/fixtures/valid/example_mono_8.txt +28 -0
- data/test/fixtures/valid/example_no_kit.txt +30 -0
- data/test/fixtures/valid/example_stereo_16.txt +28 -0
- data/test/fixtures/valid/example_stereo_8.txt +28 -0
- data/test/fixtures/valid/example_with_empty_track.txt +10 -0
- data/test/fixtures/valid/example_with_kit.txt +34 -0
- data/test/fixtures/valid/no_tempo.txt +8 -0
- data/test/fixtures/valid/pattern_with_overflow.txt +9 -0
- data/test/fixtures/valid/repeats_not_specified.txt +10 -0
- data/test/fixtures/yaml/song_yaml.txt +30 -0
- data/test/includes.rb +11 -4
- data/test/integration.rb +100 -0
- data/test/kit_test.rb +39 -39
- data/test/pattern_test.rb +119 -71
- data/test/song_test.rb +87 -62
- data/test/songoptimizer_test.rb +162 -0
- data/test/songparser_test.rb +36 -165
- data/test/sounds/agogo_high_mono_16.wav +0 -0
- data/test/sounds/agogo_high_mono_8.wav +0 -0
- data/test/sounds/agogo_high_stereo_16.wav +0 -0
- data/test/sounds/agogo_high_stereo_8.wav +0 -0
- data/test/sounds/agogo_low_mono_16.wav +0 -0
- data/test/sounds/agogo_low_mono_8.wav +0 -0
- data/test/sounds/agogo_low_stereo_16.wav +0 -0
- data/test/sounds/agogo_low_stereo_8.wav +0 -0
- data/test/sounds/bass2_mono_16.wav +0 -0
- data/test/sounds/bass2_mono_8.wav +0 -0
- data/test/sounds/bass2_stereo_16.wav +0 -0
- data/test/sounds/bass2_stereo_8.wav +0 -0
- data/test/sounds/bass_mono_8.wav +0 -0
- data/test/sounds/bass_stereo_16.wav +0 -0
- data/test/sounds/bass_stereo_8.wav +0 -0
- data/test/sounds/clave_high_mono_16.wav +0 -0
- data/test/sounds/clave_high_mono_8.wav +0 -0
- data/test/sounds/clave_high_stereo_16.wav +0 -0
- data/test/sounds/clave_high_stereo_8.wav +0 -0
- data/test/sounds/clave_low_mono_16.wav +0 -0
- data/test/sounds/clave_low_mono_8.wav +0 -0
- data/test/sounds/clave_low_stereo_16.wav +0 -0
- data/test/sounds/clave_low_stereo_8.wav +0 -0
- data/test/sounds/conga_high_mono_16.wav +0 -0
- data/test/sounds/conga_high_mono_8.wav +0 -0
- data/test/sounds/conga_high_stereo_16.wav +0 -0
- data/test/sounds/conga_high_stereo_8.wav +0 -0
- data/test/sounds/conga_low_mono_16.wav +0 -0
- data/test/sounds/conga_low_mono_8.wav +0 -0
- data/test/sounds/conga_low_stereo_16.wav +0 -0
- data/test/sounds/conga_low_stereo_8.wav +0 -0
- data/test/sounds/cowbell_high_mono_16.wav +0 -0
- data/test/sounds/cowbell_high_mono_8.wav +0 -0
- data/test/sounds/cowbell_high_stereo_16.wav +0 -0
- data/test/sounds/cowbell_high_stereo_8.wav +0 -0
- data/test/sounds/cowbell_low_mono_16.wav +0 -0
- data/test/sounds/cowbell_low_mono_8.wav +0 -0
- data/test/sounds/cowbell_low_stereo_16.wav +0 -0
- data/test/sounds/cowbell_low_stereo_8.wav +0 -0
- data/test/sounds/hh_closed_mono_16.wav +0 -0
- data/test/sounds/hh_closed_mono_8.wav +0 -0
- data/test/sounds/hh_closed_stereo_16.wav +0 -0
- data/test/sounds/hh_closed_stereo_8.wav +0 -0
- data/test/sounds/hh_open_mono_16.wav +0 -0
- data/test/sounds/hh_open_mono_8.wav +0 -0
- data/test/sounds/hh_open_stereo_16.wav +0 -0
- data/test/sounds/hh_open_stereo_8.wav +0 -0
- data/test/sounds/ride_mono_16.wav +0 -0
- data/test/sounds/ride_mono_8.wav +0 -0
- data/test/sounds/ride_stereo_16.wav +0 -0
- data/test/sounds/ride_stereo_8.wav +0 -0
- data/test/sounds/rim_mono_16.wav +0 -0
- data/test/sounds/rim_mono_8.wav +0 -0
- data/test/sounds/rim_stereo_16.wav +0 -0
- data/test/sounds/rim_stereo_8.wav +0 -0
- data/test/sounds/sine-mono-8bit.wav +0 -0
- data/test/sounds/snare2_mono_16.wav +0 -0
- data/test/sounds/snare2_mono_8.wav +0 -0
- data/test/sounds/snare2_stereo_16.wav +0 -0
- data/test/sounds/snare2_stereo_8.wav +0 -0
- data/test/sounds/snare_mono_16.wav +0 -0
- data/test/sounds/snare_mono_8.wav +0 -0
- data/test/sounds/snare_stereo_16.wav +0 -0
- data/test/sounds/snare_stereo_8.wav +0 -0
- data/test/sounds/tom1_mono_16.wav +0 -0
- data/test/sounds/tom1_mono_8.wav +0 -0
- data/test/sounds/tom1_stereo_16.wav +0 -0
- data/test/sounds/tom1_stereo_8.wav +0 -0
- data/test/sounds/tom2_mono_16.wav +0 -0
- data/test/sounds/tom2_mono_8.wav +0 -0
- data/test/sounds/tom2_stereo_16.wav +0 -0
- data/test/sounds/tom2_stereo_8.wav +0 -0
- data/test/sounds/tom3_mono_16.wav +0 -0
- data/test/sounds/tom3_mono_8.wav +0 -0
- data/test/sounds/tom3_stereo_16.wav +0 -0
- data/test/sounds/tom3_stereo_8.wav +0 -0
- data/test/sounds/tom4_mono_16.wav +0 -0
- data/test/sounds/tom4_mono_8.wav +0 -0
- data/test/sounds/tom4_stereo_16.wav +0 -0
- data/test/sounds/tom4_stereo_8.wav +0 -0
- data/test/sounds/tone.wav +0 -0
- data/test/track_test.rb +78 -72
- metadata +277 -15
data/README.markdown
CHANGED
@@ -1,7 +1,7 @@
|
|
1
|
-
BEATS
|
2
|
-
|
1
|
+
BEATS Drum Machine
|
2
|
+
------------------
|
3
3
|
|
4
|
-
BEATS is a drum machine written in pure Ruby. Feed it a song notated in YAML, and it will produce a precision-milled
|
4
|
+
BEATS is a command-line drum machine written in pure Ruby. Feed it a song notated in YAML, and it will produce a precision-milled *.wav file of impeccable timing and feel. Here's an example song:
|
5
5
|
|
6
6
|
Song:
|
7
7
|
Tempo: 120
|
@@ -29,4 +29,29 @@ BEATS is a drum machine written in pure Ruby. Feed it a song notated in YAML, an
|
|
29
29
|
- sounds/tom4.wav: ...........X....
|
30
30
|
- sounds/tom2.wav: ..............X.
|
31
31
|
|
32
|
-
|
32
|
+
And [here is what it sounds like](http://beatsdrummachine.com/beat.mp3) after getting the BEATS treatment. What a glorious groove!
|
33
|
+
|
34
|
+
Current Status
|
35
|
+
--------------
|
36
|
+
|
37
|
+
The latest stable version of BEATS is 1.2.0. It was just released! It brings significant performance and architectural improvements. It also contains a few bug fixes.
|
38
|
+
|
39
|
+
Since it was just released, I'm not sure just yet what will be coming in 1.3.0.
|
40
|
+
|
41
|
+
|
42
|
+
Installation
|
43
|
+
------------
|
44
|
+
|
45
|
+
To install the latest stable version (1.2.0), run the following from the command line:
|
46
|
+
|
47
|
+
sudo gem install beats
|
48
|
+
|
49
|
+
You can then run BEATS from the command-line using the `beats` command.
|
50
|
+
|
51
|
+
BEATS is not very useful unless you have some sounds to use with it. You can download some example sounds from [http://beatsdrummachine.com](http://beatsdrummachine.com).
|
52
|
+
|
53
|
+
|
54
|
+
Usage
|
55
|
+
-----
|
56
|
+
|
57
|
+
BEATS runs from the command-line. Run `beats -h` to see the available options. For more detailed instructions, visit [http://beatsdrummachine.com](http://beatsdrummachine.com)
|
data/bin/beats
CHANGED
@@ -1,20 +1,23 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
|
-
|
4
|
-
|
5
|
-
|
6
|
-
require File.dirname(__FILE__) + "/../lib/pattern"
|
7
|
-
require File.dirname(__FILE__) + "/../lib/track"
|
8
|
-
require "rubygems"
|
3
|
+
start_time = Time.now
|
4
|
+
|
5
|
+
$:.unshift File.dirname(__FILE__) + "/.."
|
9
6
|
require "optparse"
|
10
7
|
require "yaml"
|
11
|
-
require "
|
12
|
-
|
13
|
-
|
14
|
-
|
8
|
+
require "lib/beats"
|
9
|
+
require "lib/song"
|
10
|
+
require "lib/songparser"
|
11
|
+
require "lib/songoptimizer"
|
12
|
+
require "lib/songsplitter"
|
13
|
+
require "lib/kit"
|
14
|
+
require "lib/pattern"
|
15
|
+
require "lib/track"
|
16
|
+
require "lib/wavefile"
|
17
|
+
require "lib/beatswavefile"
|
15
18
|
|
16
19
|
def parse_options
|
17
|
-
options = {:split => false, :pattern =>
|
20
|
+
options = {:split => false, :pattern => nil}
|
18
21
|
|
19
22
|
optparse = OptionParser.new do |opts|
|
20
23
|
opts.on('-s', '--split', "Save each track to an individual wave file") do
|
@@ -24,82 +27,44 @@ def parse_options
|
|
24
27
|
opts.on('-p', '--pattern PATTERN_NAME', "Output a single pattern instead of the whole song" ) do |p|
|
25
28
|
options[:pattern] = p
|
26
29
|
end
|
27
|
-
|
30
|
+
|
28
31
|
opts.on('-v', '--version', "Display version number and exit") do
|
29
|
-
puts "BEATS v#{BEATS_VERSION}"
|
32
|
+
puts "BEATS v#{Beats::BEATS_VERSION}"
|
30
33
|
exit
|
31
34
|
end
|
32
|
-
|
35
|
+
|
33
36
|
opts.on( '-h', '--help', "Display this screen and exit" ) do
|
34
37
|
puts opts
|
35
38
|
exit
|
36
39
|
end
|
37
40
|
end
|
38
41
|
optparse.parse!
|
39
|
-
|
40
|
-
return options
|
41
|
-
end
|
42
42
|
|
43
|
-
|
44
|
-
output = WaveFile.new(num_channels, SAMPLE_RATE, bits_per_sample)
|
45
|
-
output.sample_data = sample_data
|
46
|
-
output.save(file_name)
|
47
|
-
return output.duration
|
43
|
+
return options
|
48
44
|
end
|
49
45
|
|
50
|
-
start = Time.now
|
51
|
-
|
52
46
|
options = parse_options
|
53
|
-
|
54
|
-
|
47
|
+
input_file_name = ARGV[0]
|
48
|
+
output_file_name = ARGV[1]
|
55
49
|
|
56
|
-
|
57
|
-
ARGV[0] = '-h'
|
58
|
-
parse_options()
|
59
|
-
end
|
60
|
-
|
61
|
-
if(output_file == nil)
|
62
|
-
output_file = File.basename(input_file, File.extname(input_file)) + ".wav"
|
63
|
-
end
|
50
|
+
beats = Beats.new(input_file_name, output_file_name, options)
|
64
51
|
|
65
52
|
begin
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
generate_samples_start = Time.now
|
71
|
-
sample_data = song_from_file.sample_data(options[:pattern], options[:split])
|
72
|
-
#puts "Time to generate sample data: #{Time.now - generate_samples_start}"
|
73
|
-
|
74
|
-
wave_write_start = Time.now
|
75
|
-
if(options[:split])
|
76
|
-
duration = nil
|
77
|
-
sample_data.keys.each {|track_name|
|
78
|
-
extension = File.extname(output_file)
|
79
|
-
file_name = File.basename(output_file, extension) + "-" + File.basename(track_name.to_s, extension) + extension
|
80
|
-
|
81
|
-
duration = save_wave_file(file_name,
|
82
|
-
song_from_file.num_channels,
|
83
|
-
song_from_file.bits_per_sample,
|
84
|
-
sample_data[track_name])
|
85
|
-
}
|
86
|
-
else
|
87
|
-
duration = save_wave_file(output_file, song_from_file.num_channels, song_from_file.bits_per_sample, sample_data)
|
88
|
-
end
|
89
|
-
|
90
|
-
puts "#{duration[:minutes]}:#{duration[:seconds].to_s.rjust(2, '0')} of audio produced in #{Time.now - start} seconds."
|
53
|
+
output = beats.run()
|
54
|
+
duration = output[:duration]
|
55
|
+
puts "#{duration[:minutes]}:#{duration[:seconds].to_s.rjust(2, '0')} of audio written in #{Time.now - start_time} seconds."
|
91
56
|
rescue Errno::ENOENT => detail
|
92
|
-
puts ""
|
93
|
-
puts "Song file '#{
|
94
|
-
puts ""
|
57
|
+
puts "\n"
|
58
|
+
puts "Song file '#{input_file_name}' not found.\n"
|
59
|
+
puts "\n"
|
95
60
|
rescue SongParseError => detail
|
96
|
-
puts ""
|
97
|
-
puts "Song file '#{
|
98
|
-
puts " #{detail}"
|
99
|
-
puts ""
|
61
|
+
puts "\n"
|
62
|
+
puts "Song file '#{input_file_name}' has an error:\n"
|
63
|
+
puts " #{detail}\n"
|
64
|
+
puts "\n"
|
100
65
|
rescue StandardError => detail
|
101
|
-
puts ""
|
102
|
-
puts "An error occured while generating sound for '#{
|
103
|
-
puts " #{detail}"
|
104
|
-
puts ""
|
105
|
-
end
|
66
|
+
puts "\n"
|
67
|
+
puts "An error occured while generating sound for '#{input_file_name}':\n"
|
68
|
+
puts " #{detail}\n"
|
69
|
+
puts "\n"
|
70
|
+
end
|
data/lib/beats.rb
ADDED
@@ -0,0 +1,57 @@
|
|
1
|
+
class Beats
|
2
|
+
BEATS_VERSION = "1.2.0"
|
3
|
+
SAMPLE_RATE = 44100
|
4
|
+
OPTIMIZED_PATTERN_LENGTH = 4
|
5
|
+
|
6
|
+
def initialize(input_file_name, output_file_name, options)
|
7
|
+
@input_file_name = input_file_name
|
8
|
+
@output_file_name = output_file_name
|
9
|
+
@options = options
|
10
|
+
end
|
11
|
+
|
12
|
+
def run
|
13
|
+
if @input_file_name == nil
|
14
|
+
ARGV[0] = '-h'
|
15
|
+
parse_options()
|
16
|
+
end
|
17
|
+
|
18
|
+
if @output_file_name == nil
|
19
|
+
@output_file_name = File.basename(@input_file_name, File.extname(@input_file_name)) + ".wav"
|
20
|
+
end
|
21
|
+
|
22
|
+
song_parser = SongParser.new()
|
23
|
+
song = song_parser.parse(File.dirname(@input_file_name), YAML.load_file(@input_file_name))
|
24
|
+
song_optimizer = SongOptimizer.new()
|
25
|
+
|
26
|
+
if @options[:pattern] != nil
|
27
|
+
pattern_name = @options[:pattern].downcase.to_sym
|
28
|
+
unless song.patterns.member?(pattern_name)
|
29
|
+
raise StandardError, "The song does not include a pattern called #{@options[:pattern]}"
|
30
|
+
end
|
31
|
+
|
32
|
+
song.structure = [pattern_name]
|
33
|
+
song.remove_unused_patterns()
|
34
|
+
end
|
35
|
+
|
36
|
+
duration = nil
|
37
|
+
if @options[:split]
|
38
|
+
song_splitter = SongSplitter.new()
|
39
|
+
split_songs = song_splitter.split(song)
|
40
|
+
split_songs.each do |track_name, split_song|
|
41
|
+
split_song = song_optimizer.optimize(split_song, OPTIMIZED_PATTERN_LENGTH)
|
42
|
+
|
43
|
+
# TODO: Move building the output file name into its own method?
|
44
|
+
extension = File.extname(@output_file_name)
|
45
|
+
file_name = File.dirname(@output_file_name) + "/" +
|
46
|
+
File.basename(@output_file_name, extension) + "-" + File.basename(track_name, extension) +
|
47
|
+
extension
|
48
|
+
duration = split_song.write_to_file(file_name)
|
49
|
+
end
|
50
|
+
else
|
51
|
+
song = song_optimizer.optimize(song, OPTIMIZED_PATTERN_LENGTH)
|
52
|
+
duration = song.write_to_file(@output_file_name)
|
53
|
+
end
|
54
|
+
|
55
|
+
return {:duration => duration}
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
# Adds some functionality to the WaveFile gem that allows for improved performance. The
|
2
|
+
# combo of open_for_appending() and write_snippet() allow a wave file to be written to
|
3
|
+
# disk in chunks, instead of all at once. This improves performance (and I would assume
|
4
|
+
# memory usage) by eliminating the need to store the entire sample data for the song in
|
5
|
+
# memory in a giant (i.e. millions of elements) array.
|
6
|
+
#
|
7
|
+
# I'm not sure these methods in their current form are suitable for the WaveFile gem.
|
8
|
+
# That's a public API so I want to be careful adding to it, and these methods fall into the
|
9
|
+
# category of "you should know what you're doing." In particular, open_for_appending() and
|
10
|
+
# write_snippet() need to be used together, and if you don't use them right your saved wave
|
11
|
+
# file will be messed up. There's probably a better API for doing this.
|
12
|
+
#
|
13
|
+
# If I figure out a better API I might add it to the WaveFile gem in the future, but until
|
14
|
+
# then I'm just putting it here. Since BEATS is a stand-alone app and not a re-usable library,
|
15
|
+
# I don't think this should be a problem.
|
16
|
+
class BeatsWaveFile < WaveFile
|
17
|
+
|
18
|
+
# Writes the header for the wave file to path, and returns an open File object that
|
19
|
+
# can be used outside the method to append the sample data. WARNING: The header contains
|
20
|
+
# a field for the total number of samples in the file. This number of samples must be
|
21
|
+
# subsequently be written to the file using write_snippet() or it won't be valid and you
|
22
|
+
# won't be able to play it.
|
23
|
+
def open_for_appending(path, num_samples)
|
24
|
+
bytes_per_sample = (@bits_per_sample / 8)
|
25
|
+
sample_data_size = num_samples * bytes_per_sample * @num_channels
|
26
|
+
|
27
|
+
# Write the header
|
28
|
+
header = CHUNK_ID
|
29
|
+
header += [HEADER_SIZE + sample_data_size].pack("V")
|
30
|
+
header += FORMAT
|
31
|
+
header += FORMAT_CHUNK_ID
|
32
|
+
header += [SUB_CHUNK1_SIZE].pack("V")
|
33
|
+
header += [PCM].pack("v")
|
34
|
+
header += [@num_channels].pack("v")
|
35
|
+
header += [@sample_rate].pack("V")
|
36
|
+
header += [@byte_rate].pack("V")
|
37
|
+
header += [@block_align].pack("v")
|
38
|
+
header += [@bits_per_sample].pack("v")
|
39
|
+
header += DATA_CHUNK_ID
|
40
|
+
header += [sample_data_size].pack("V")
|
41
|
+
|
42
|
+
file = File.open(path, "w")
|
43
|
+
file.syswrite(header)
|
44
|
+
|
45
|
+
return file
|
46
|
+
end
|
47
|
+
|
48
|
+
# Appending sample_data to file, which is assumed to be open. Should be used in
|
49
|
+
# conjunction with open_for_appending(). The File object returned by that method
|
50
|
+
# should be passed in here. WARNING: you are responsible for writing the correct
|
51
|
+
# number of samples to the file, with 1 or more calls to this method. The caller
|
52
|
+
# of this method is also responsible for closing the File object when finished.
|
53
|
+
def write_snippet(file, sample_data)
|
54
|
+
if @bits_per_sample == 8
|
55
|
+
pack_code = "C*"
|
56
|
+
elsif @bits_per_sample == 16
|
57
|
+
pack_code = "s*"
|
58
|
+
end
|
59
|
+
|
60
|
+
if @num_channels == 1
|
61
|
+
file.syswrite(sample_data.pack(pack_code))
|
62
|
+
else
|
63
|
+
file.syswrite(sample_data.flatten.pack(pack_code))
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
def calculate_duration(sample_rate, total_samples)
|
68
|
+
samples_per_millisecond = sample_rate / 1000.0
|
69
|
+
samples_per_second = sample_rate
|
70
|
+
samples_per_minute = samples_per_second * 60
|
71
|
+
samples_per_hour = samples_per_minute * 60
|
72
|
+
hours, minutes, seconds, milliseconds = 0, 0, 0, 0
|
73
|
+
|
74
|
+
if total_samples >= samples_per_hour
|
75
|
+
hours = total_samples / samples_per_hour
|
76
|
+
total_samples -= samples_per_hour * hours
|
77
|
+
end
|
78
|
+
|
79
|
+
if total_samples >= samples_per_minute
|
80
|
+
minutes = total_samples / samples_per_minute
|
81
|
+
total_samples -= samples_per_minute * minutes
|
82
|
+
end
|
83
|
+
|
84
|
+
if total_samples >= samples_per_second
|
85
|
+
seconds = total_samples / samples_per_second
|
86
|
+
total_samples -= samples_per_second * seconds
|
87
|
+
end
|
88
|
+
|
89
|
+
milliseconds = (total_samples / samples_per_millisecond).floor
|
90
|
+
|
91
|
+
return { :hours => hours, :minutes => minutes, :seconds => seconds, :milliseconds => milliseconds }
|
92
|
+
end
|
93
|
+
end
|
data/lib/kit.rb
CHANGED
@@ -1,38 +1,74 @@
|
|
1
|
+
# Raised when trying to load a sound file which can't be found at the path specified
|
1
2
|
class SoundNotFoundError < RuntimeError; end
|
2
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.
|
14
|
+
#
|
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.
|
20
|
+
#
|
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).
|
3
27
|
class Kit
|
4
28
|
PATH_SEPARATOR = File.const_get("SEPARATOR")
|
5
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.
|
6
32
|
def initialize(base_path)
|
7
33
|
@base_path = base_path
|
34
|
+
@label_mappings = {}
|
8
35
|
@sounds = {}
|
9
|
-
@num_channels =
|
10
|
-
@bits_per_sample =
|
36
|
+
@num_channels = 1
|
37
|
+
@bits_per_sample = 16 # Only use 16-bit files as output. Supporting 8-bit output
|
38
|
+
# means extra complication for no real gain (I'm skeptical
|
39
|
+
# anyone would explicitly want 8-bit output instead of 16-bit.
|
11
40
|
end
|
12
41
|
|
42
|
+
# Adds a new sound to the kit.
|
13
43
|
def add(name, path)
|
14
|
-
|
15
|
-
|
16
|
-
|
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
|
17
50
|
end
|
18
51
|
|
19
52
|
begin
|
20
|
-
wavefile = WaveFile.open(
|
53
|
+
wavefile = WaveFile.open(full_path)
|
21
54
|
rescue
|
22
|
-
|
55
|
+
# TODO: Raise different error if sound is in an unsupported format
|
56
|
+
raise SoundNotFoundError, "Sound file #{full_path} not found."
|
23
57
|
end
|
24
58
|
|
25
59
|
@sounds[name] = wavefile
|
60
|
+
if name != path
|
61
|
+
@label_mappings[name] = path
|
62
|
+
end
|
26
63
|
|
27
64
|
if wavefile.num_channels > @num_channels
|
28
65
|
@num_channels = wavefile.num_channels
|
29
66
|
end
|
30
|
-
if wavefile.bits_per_sample > @bits_per_sample
|
31
|
-
@bits_per_sample = wavefile.bits_per_sample
|
32
|
-
end
|
33
67
|
end
|
34
68
|
end
|
35
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.
|
36
72
|
def get_sample_data(name)
|
37
73
|
wavefile = @sounds[name]
|
38
74
|
|
@@ -46,9 +82,29 @@ class Kit
|
|
46
82
|
end
|
47
83
|
end
|
48
84
|
|
85
|
+
# Returns the number of sounds currently contained in the kit.
|
49
86
|
def size
|
50
87
|
return @sounds.length
|
51
88
|
end
|
52
89
|
|
53
|
-
|
90
|
+
# Produces nicer looking output than the default version of to_yaml().
|
91
|
+
def to_yaml(indent_space_count = 0)
|
92
|
+
yaml = ""
|
93
|
+
longest_label_mapping_length =
|
94
|
+
@label_mappings.keys.inject(0) do |max_length, name|
|
95
|
+
(name.to_s.length > max_length) ? name.to_s.length : max_length
|
96
|
+
end
|
97
|
+
|
98
|
+
if @label_mappings.length > 0
|
99
|
+
yaml += " " * indent_space_count + "Kit:\n"
|
100
|
+
ljust_amount = longest_label_mapping_length + 1 # The +1 is for the trailing ":"
|
101
|
+
@label_mappings.sort.each do |label, path|
|
102
|
+
yaml += " " * indent_space_count + " - #{(label + ":").ljust(ljust_amount)} #{path}\n"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
return yaml
|
107
|
+
end
|
108
|
+
|
109
|
+
attr_reader :base_path, :label_mappings, :bits_per_sample, :num_channels
|
54
110
|
end
|
data/lib/pattern.rb
CHANGED
@@ -1,133 +1,178 @@
|
|
1
1
|
class Pattern
|
2
2
|
def initialize(name)
|
3
3
|
@name = name
|
4
|
-
@cache = {}
|
5
4
|
@tracks = {}
|
6
5
|
end
|
7
6
|
|
8
|
-
|
9
|
-
|
7
|
+
# Adds a new track to the pattern.
|
8
|
+
def track(name, wave_data, rhythm)
|
9
|
+
new_track = Track.new(name, wave_data, rhythm)
|
10
10
|
@tracks[new_track.name] = new_track
|
11
11
|
|
12
12
|
# If the new track is longer than any of the previously added tracks,
|
13
13
|
# pad the other tracks with trailing . to make them all the same length.
|
14
14
|
# Necessary to prevent incorrect overflow calculations for tracks.
|
15
|
-
longest_track_length =
|
16
|
-
@tracks.values.each
|
17
|
-
if
|
18
|
-
longest_track_length
|
15
|
+
longest_track_length = tick_count()
|
16
|
+
@tracks.values.each do |track|
|
17
|
+
if track.rhythm.length < longest_track_length
|
18
|
+
track.rhythm += "." * (longest_track_length - track.rhythm.length)
|
19
19
|
end
|
20
|
-
|
21
|
-
@tracks.values.each {|track|
|
22
|
-
if(track.pattern.length < longest_track_length)
|
23
|
-
track.pattern += "." * (longest_track_length - track.pattern.length)
|
24
|
-
end
|
25
|
-
}
|
20
|
+
end
|
26
21
|
|
27
22
|
return new_track
|
28
23
|
end
|
29
24
|
|
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.
|
30
27
|
def sample_length(tick_sample_length)
|
31
28
|
@tracks.keys.collect {|track_name| @tracks[track_name].sample_length(tick_sample_length) }.max || 0
|
32
29
|
end
|
33
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.
|
34
33
|
def sample_length_with_overflow(tick_sample_length)
|
35
34
|
@tracks.keys.collect {|track_name| @tracks[track_name].sample_length_with_overflow(tick_sample_length) }.max || 0
|
36
35
|
end
|
37
36
|
|
38
|
-
def
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
37
|
+
def tick_count
|
38
|
+
return @tracks.values.collect {|track| track.rhythm.length }.max || 0
|
39
|
+
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
|
+
|
53
|
+
# Returns whether or not this pattern has the same number of tracks as other_pattern, and that
|
54
|
+
# each of the tracks has the same name and rhythm. Ordering of tracks does not matter; will
|
55
|
+
# return true if the two patterns have the same tracks but in a different ordering.
|
56
|
+
def same_tracks_as?(other_pattern)
|
57
|
+
@tracks.keys.each do |track_name|
|
58
|
+
other_pattern_track = other_pattern.tracks[track_name]
|
59
|
+
if other_pattern_track == nil || @tracks[track_name].rhythm != other_pattern_track.rhythm
|
60
|
+
return false
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
return @tracks.length == other_pattern.tracks.length
|
65
|
+
end
|
66
|
+
|
67
|
+
# Returns a YAML representation of the Pattern. Produces nicer looking output than the default
|
68
|
+
# version of to_yaml().
|
69
|
+
def to_yaml
|
70
|
+
longest_track_name_length =
|
71
|
+
@tracks.keys.inject(0) do |max_length, name|
|
72
|
+
(name.to_s.length > max_length) ? name.to_s.length : max_length
|
73
|
+
end
|
74
|
+
ljust_amount = longest_track_name_length + 7
|
75
|
+
|
76
|
+
yaml = "#{@name.to_s.capitalize}:\n"
|
77
|
+
@tracks.keys.sort.each do |track_name|
|
78
|
+
yaml += " - #{track_name}:".ljust(ljust_amount)
|
79
|
+
yaml += "#{@tracks[track_name].rhythm}\n"
|
43
80
|
end
|
81
|
+
|
82
|
+
return yaml
|
44
83
|
end
|
45
84
|
|
46
85
|
attr_accessor :tracks, :name
|
47
|
-
|
86
|
+
|
48
87
|
private
|
49
88
|
|
50
|
-
def
|
51
|
-
# If we've already encountered this pattern with the same incoming overflow before,
|
52
|
-
# return the pre-mixed down version from the cache.
|
53
|
-
if(@cache.member?(incoming_overflow))
|
54
|
-
return @cache[incoming_overflow]
|
55
|
-
end
|
56
|
-
|
57
|
-
fill_value = (num_channels == 1) ? 0 : [].fill(0, 0, num_channels)
|
89
|
+
def generate_main_sample_data(tick_sample_length, num_channels)
|
58
90
|
track_names = @tracks.keys
|
59
91
|
primary_sample_data = []
|
60
92
|
overflow_sample_data = {}
|
61
93
|
actual_sample_length = sample_length(tick_sample_length)
|
62
|
-
|
63
|
-
if
|
64
|
-
|
65
|
-
|
66
|
-
track_names.each {|track_name|
|
67
|
-
temp = @tracks[track_name].sample_data(tick_sample_length, incoming_overflow[track_name])
|
68
|
-
|
69
|
-
track_samples = temp[:primary]
|
70
|
-
if(num_channels == 1)
|
71
|
-
(0...track_samples.length).each {|i| primary_sample_data[i] += track_samples[i] }
|
72
|
-
else
|
73
|
-
(0...track_samples.length).each {|i|
|
74
|
-
primary_sample_data[i][0] += track_samples[i][0]
|
75
|
-
primary_sample_data[i][1] += track_samples[i][1]
|
76
|
-
}
|
77
|
-
end
|
78
|
-
|
79
|
-
overflow_sample_data[track_name] = temp[:overflow]
|
80
|
-
}
|
81
|
-
end
|
94
|
+
|
95
|
+
if @intermediate_cache == nil
|
96
|
+
track_names.each do |track_name|
|
97
|
+
temp = @tracks[track_name].sample_data(tick_sample_length)
|
82
98
|
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
if(!track_names.member?(track_name) && incoming_overflow[track_name].length > 0)
|
87
|
-
if(num_channels == 1)
|
88
|
-
(0...incoming_overflow[track_name].length).each {|i| primary_sample_data[i] += incoming_overflow[track_name][i]}
|
99
|
+
if primary_sample_data == []
|
100
|
+
primary_sample_data = temp[:primary]
|
101
|
+
overflow_sample_data[track_name] = temp[:overflow]
|
89
102
|
else
|
90
|
-
|
91
|
-
|
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]
|
92
114
|
end
|
93
115
|
end
|
94
|
-
}
|
95
116
|
|
96
|
-
|
97
|
-
if(num_channels == 1)
|
98
|
-
primary_sample_data = primary_sample_data.map {|sample| (sample / num_tracks_in_song).round }
|
117
|
+
@intermediate_cache = {:primary => primary_sample_data.dup, :overflow => overflow_sample_data.dup}
|
99
118
|
else
|
100
|
-
primary_sample_data =
|
119
|
+
primary_sample_data = @intermediate_cache[:primary].dup
|
120
|
+
overflow_sample_data = @intermediate_cache[:overflow].dup
|
101
121
|
end
|
102
|
-
|
103
|
-
|
104
|
-
mixdown_sample_data = {:primary => primary_sample_data, :overflow => overflow_sample_data}
|
105
|
-
@cache[incoming_overflow] = mixdown_sample_data
|
106
|
-
|
107
|
-
return mixdown_sample_data
|
122
|
+
|
123
|
+
return primary_sample_data, overflow_sample_data
|
108
124
|
end
|
109
125
|
|
110
|
-
def
|
111
|
-
|
112
|
-
|
113
|
-
|
114
|
-
|
115
|
-
|
116
|
-
temp = @tracks[track_name].sample_data(tick_sample_length, incoming_overflow[track_name])
|
117
|
-
primary_sample_data[track_name] = temp[:primary]
|
118
|
-
overflow_sample_data[track_name] = temp[:overflow]
|
119
|
-
}
|
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
|
120
132
|
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
127
|
-
|
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
|
128
160
|
end
|
129
|
-
|
130
|
-
|
131
|
-
return
|
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
|
132
177
|
end
|
133
178
|
end
|