beats 1.2.1 → 1.2.2
Sign up to get free protection for your applications and to get access to all the features.
- data/README.markdown +11 -11
- data/Rakefile +6 -0
- data/bin/beats +37 -24
- data/lib/audioengine.rb +11 -3
- data/lib/beats.rb +41 -21
- data/lib/beatswavefile.rb +1 -1
- data/lib/pattern.rb +5 -0
- data/lib/song.rb +8 -15
- data/lib/songoptimizer.rb +26 -16
- data/lib/songparser.rb +26 -16
- data/lib/track.rb +7 -1
- data/test/audioengine_test.rb +1 -1
- data/test/audioutils_test.rb +1 -1
- data/test/includes.rb +3 -1
- data/test/{integration.rb → integration_test.rb} +16 -5
- data/test/kit_test.rb +1 -1
- data/test/pattern_test.rb +1 -1
- data/test/patternexpander_test.rb +1 -1
- data/test/song_test.rb +18 -15
- data/test/songoptimizer_test.rb +1 -1
- data/test/songparser_test.rb +1 -1
- data/test/track_test.rb +1 -1
- metadata +19 -22
- data/lib/wavefile.rb +0 -475
data/README.markdown
CHANGED
@@ -5,7 +5,7 @@ BEATS is a command-line drum machine written in pure Ruby. Feed it a song notate
|
|
5
5
|
|
6
6
|
Song:
|
7
7
|
Tempo: 120
|
8
|
-
|
8
|
+
Flow:
|
9
9
|
- Verse: x2
|
10
10
|
- Chorus: x4
|
11
11
|
- Verse: x2
|
@@ -35,24 +35,24 @@ And [here's what it sounds like](http://beatsdrummachine.com/beat.mp3) after get
|
|
35
35
|
Current Status
|
36
36
|
--------------
|
37
37
|
|
38
|
-
The latest stable version of BEATS is 1.2.
|
38
|
+
The latest stable version of BEATS is 1.2.2, released on June 12, 2011. This is a minor release which includes two bug fixes:
|
39
39
|
|
40
|
-
*
|
41
|
-
*
|
42
|
-
* A pattern can contain multiple tracks that use the same sound. Previously, BEATS would pick one of those tracks as the 'winner', and the other tracks wouldn't be played.
|
43
|
-
* Bug fix: A better error message is displayed if a sound file is in an unsupported format (such as MP3), or is not even a sound file.
|
40
|
+
* Bug fix: Compatibility issues with Windows
|
41
|
+
* Bug fix: Return the correct status code when BEATS terminates, to improve scriptability.
|
44
42
|
|
45
43
|
|
46
44
|
Installation
|
47
45
|
------------
|
48
46
|
|
49
|
-
To install the latest stable version (1.2.
|
47
|
+
To install the latest stable version (1.2.2) from [rubygems.org](http://rubygems.org/gems/beats), run the following from the command line:
|
50
48
|
|
51
|
-
|
49
|
+
gem install beats
|
52
50
|
|
53
|
-
|
51
|
+
Note that if you are installing using the default version of Ruby that comes with MacOS X, you might get a file permission error. If that happens, use `sudo gem install beats` instead. If you are using RVM, plain `gem install beats` should work fine.
|
54
52
|
|
55
|
-
|
53
|
+
Once installed, you can then run BEATS from the command-line using the `beats` command.
|
54
|
+
|
55
|
+
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/download#drum-kits).
|
56
56
|
|
57
57
|
|
58
58
|
Usage
|
@@ -66,7 +66,7 @@ The BEATS wiki also has a [Getting Started](https://github.com/jstrait/beats/wik
|
|
66
66
|
Found a Bug? Have a Suggestion? Want to Contribute?
|
67
67
|
---------------------------------------------------
|
68
68
|
|
69
|
-
Contact me (Joel Strait) by sending a GitHub message.
|
69
|
+
Contact me (Joel Strait) by sending a GitHub message or opening a GitHub issue.
|
70
70
|
|
71
71
|
|
72
72
|
License
|
data/Rakefile
ADDED
data/bin/beats
CHANGED
@@ -5,23 +5,26 @@ start_time = Time.now
|
|
5
5
|
$:.unshift File.dirname(__FILE__) + "/.."
|
6
6
|
require "optparse"
|
7
7
|
require "yaml"
|
8
|
-
require "
|
9
|
-
require "lib/beatswavefile"
|
8
|
+
require "wavefile"
|
10
9
|
require "lib/audioengine"
|
11
10
|
require "lib/audioutils"
|
12
11
|
require "lib/beats"
|
12
|
+
require "lib/beatswavefile"
|
13
13
|
require "lib/kit"
|
14
14
|
require "lib/pattern"
|
15
|
-
require "lib/patternexpander"
|
16
15
|
require "lib/song"
|
17
16
|
require "lib/songoptimizer"
|
18
17
|
require "lib/songparser"
|
19
18
|
require "lib/track"
|
20
19
|
|
21
|
-
|
20
|
+
USAGE_INSTRUCTIONS = ""
|
21
|
+
|
22
|
+
def parse_options()
|
22
23
|
options = {:split => false, :pattern => nil}
|
23
24
|
|
24
25
|
optparse = OptionParser.new do |opts|
|
26
|
+
opts.banner = "usage: beats [options] input_file [output_file]"
|
27
|
+
|
25
28
|
opts.on('-s', '--split', "Save each track to an individual wave file") do
|
26
29
|
options[:split] = true
|
27
30
|
end
|
@@ -40,33 +43,43 @@ def parse_options
|
|
40
43
|
exit
|
41
44
|
end
|
42
45
|
end
|
43
|
-
|
46
|
+
|
47
|
+
USAGE_INSTRUCTIONS << optparse.to_s
|
48
|
+
optparse.parse!()
|
44
49
|
|
45
50
|
return options
|
46
51
|
end
|
47
52
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
+
def print_error(error, input_file_name)
|
54
|
+
case error
|
55
|
+
when OptionParser::InvalidOption
|
56
|
+
puts "beats: illegal option #{error.args.join(' ')}"
|
57
|
+
puts USAGE_INSTRUCTIONS
|
58
|
+
when Errno::ENOENT
|
59
|
+
puts "Song file '#{input_file_name}' not found.\n"
|
60
|
+
when SongParseError
|
61
|
+
puts "Song file '#{input_file_name}' has an error:\n"
|
62
|
+
puts " #{error}\n"
|
63
|
+
when StandardError
|
64
|
+
puts "An error occured while generating sound for '#{input_file_name}':\n"
|
65
|
+
puts " #{error}\n"
|
66
|
+
else
|
67
|
+
puts "An unexpected error occured while running BEATS:"
|
68
|
+
puts " #{error}\n"
|
69
|
+
end
|
70
|
+
end
|
53
71
|
|
54
72
|
begin
|
73
|
+
options = parse_options()
|
74
|
+
input_file_name = ARGV[0]
|
75
|
+
output_file_name = ARGV[1]
|
76
|
+
|
77
|
+
beats = Beats.new(input_file_name, output_file_name, options)
|
78
|
+
|
55
79
|
output = beats.run()
|
56
80
|
duration = output[:duration]
|
57
81
|
puts "#{duration[:minutes]}:#{duration[:seconds].to_s.rjust(2, '0')} of audio written in #{Time.now - start_time} seconds."
|
58
|
-
rescue
|
59
|
-
|
60
|
-
|
61
|
-
puts "\n"
|
62
|
-
rescue SongParseError => detail
|
63
|
-
puts "\n"
|
64
|
-
puts "Song file '#{input_file_name}' has an error:\n"
|
65
|
-
puts " #{detail}\n"
|
66
|
-
puts "\n"
|
67
|
-
rescue StandardError => detail
|
68
|
-
puts "\n"
|
69
|
-
puts "An error occured while generating sound for '#{input_file_name}':\n"
|
70
|
-
puts " #{detail}\n"
|
71
|
-
puts "\n"
|
82
|
+
rescue => error
|
83
|
+
print_error(error, input_file_name)
|
84
|
+
exit(false)
|
72
85
|
end
|
data/lib/audioengine.rb
CHANGED
@@ -1,6 +1,14 @@
|
|
1
|
-
# This class actually generates the output
|
2
|
-
#
|
3
|
-
# produce
|
1
|
+
# This class actually generates the output audio data that is saved to disk.
|
2
|
+
#
|
3
|
+
# To produce audio data, it needs two things: a Song and a Kit. The Song tells
|
4
|
+
# it which sounds to trigger and when, while the Kit provides the sample data
|
5
|
+
# for each of these sounds.
|
6
|
+
#
|
7
|
+
# Example usage, assuming song and kit are already defined:
|
8
|
+
#
|
9
|
+
# engine = AudioEngine.new(song, kit)
|
10
|
+
# engine.write_to_file("my_song.wav")
|
11
|
+
#
|
4
12
|
class AudioEngine
|
5
13
|
SAMPLE_RATE = 44100
|
6
14
|
PACK_CODE = "s*" # All output sample data is assumed to be 16-bit
|
data/lib/beats.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
class Beats
|
2
|
-
BEATS_VERSION = "1.2.
|
3
|
-
|
2
|
+
BEATS_VERSION = "1.2.2"
|
3
|
+
|
4
4
|
# Each pattern in the song will be split up into sub patterns that have at most this many steps.
|
5
5
|
# In general, audio for several shorter patterns can be generated more quickly than for one long
|
6
6
|
# pattern, and can also be cached more effectively.
|
@@ -8,49 +8,69 @@ class Beats
|
|
8
8
|
|
9
9
|
def initialize(input_file_name, output_file_name, options)
|
10
10
|
@input_file_name = input_file_name
|
11
|
+
|
12
|
+
if output_file_name == nil
|
13
|
+
output_file_name = File.basename(input_file_name, File.extname(input_file_name)) + ".wav"
|
14
|
+
end
|
11
15
|
@output_file_name = output_file_name
|
16
|
+
|
12
17
|
@options = options
|
13
18
|
end
|
14
19
|
|
15
20
|
def run
|
16
|
-
|
17
|
-
ARGV[0] = '-h'
|
18
|
-
parse_options()
|
19
|
-
end
|
21
|
+
song, kit = SongParser.new().parse(File.dirname(@input_file_name), File.read(@input_file_name))
|
20
22
|
|
21
|
-
|
22
|
-
|
23
|
-
end
|
23
|
+
song = normalize_for_pattern_option(song)
|
24
|
+
songs_to_generate = normalize_for_split_option(song)
|
24
25
|
|
25
|
-
|
26
|
-
song, kit = song_parser.parse(File.dirname(@input_file_name), File.read(@input_file_name))
|
26
|
+
duration = nil
|
27
27
|
song_optimizer = SongOptimizer.new()
|
28
|
+
songs_to_generate.each do |output_file_name, song|
|
29
|
+
song = song_optimizer.optimize(song, OPTIMIZED_PATTERN_LENGTH)
|
30
|
+
duration = AudioEngine.new(song, kit).write_to_file(output_file_name)
|
31
|
+
end
|
32
|
+
|
33
|
+
return {:duration => duration}
|
34
|
+
end
|
35
|
+
|
36
|
+
private
|
28
37
|
|
29
|
-
|
30
|
-
|
38
|
+
# If the -p option is used, transform the song into one whose flow consists of
|
39
|
+
# playing that single pattern once.
|
40
|
+
def normalize_for_pattern_option(song)
|
31
41
|
unless @options[:pattern] == nil
|
32
42
|
pattern_name = @options[:pattern].downcase.to_sym
|
33
|
-
|
43
|
+
|
44
|
+
unless song.patterns.has_key?(pattern_name)
|
45
|
+
raise StandardError, "The song does not include a pattern called #{pattern_name}"
|
46
|
+
end
|
47
|
+
|
48
|
+
song.flow = [pattern_name]
|
49
|
+
song.remove_unused_patterns()
|
34
50
|
end
|
35
51
|
|
36
|
-
|
52
|
+
return song
|
53
|
+
end
|
54
|
+
|
55
|
+
# Returns a hash of file name => song object for each song that should go through the audio engine
|
56
|
+
def normalize_for_split_option(song)
|
57
|
+
songs_to_generate = {}
|
58
|
+
|
37
59
|
if @options[:split]
|
38
60
|
split_songs = song.split()
|
39
61
|
split_songs.each do |track_name, split_song|
|
40
|
-
split_song = song_optimizer.optimize(split_song, OPTIMIZED_PATTERN_LENGTH)
|
41
|
-
|
42
62
|
# TODO: Move building the output file name into its own method?
|
43
63
|
extension = File.extname(@output_file_name)
|
44
64
|
file_name = File.dirname(@output_file_name) + "/" +
|
45
65
|
File.basename(@output_file_name, extension) + "-" + File.basename(track_name, extension) +
|
46
66
|
extension
|
47
|
-
|
67
|
+
|
68
|
+
songs_to_generate[file_name] = split_song
|
48
69
|
end
|
49
70
|
else
|
50
|
-
|
51
|
-
duration = AudioEngine.new(song, kit).write_to_file(@output_file_name)
|
71
|
+
songs_to_generate[@output_file_name] = song
|
52
72
|
end
|
53
73
|
|
54
|
-
return
|
74
|
+
return songs_to_generate
|
55
75
|
end
|
56
76
|
end
|
data/lib/beatswavefile.rb
CHANGED
@@ -16,7 +16,7 @@ class BeatsWaveFile < WaveFile
|
|
16
16
|
# this number of samples) must be subsequently be written to the file before it is closed
|
17
17
|
# or it won't be valid and you won't be able to play it.
|
18
18
|
def open_for_appending(path)
|
19
|
-
file = File.open(path, "
|
19
|
+
file = File.open(path, "wb")
|
20
20
|
write_header(file, 0)
|
21
21
|
|
22
22
|
return file
|
data/lib/pattern.rb
CHANGED
@@ -1,3 +1,8 @@
|
|
1
|
+
# Domain object which models one or more Tracks playing a part of the song at the same time.
|
2
|
+
# For example, a bass drum, snare drum, and hi-hat track playing the song's chorus.
|
3
|
+
#
|
4
|
+
# This object is like sheet music; the AudioEngine is responsible creating actual
|
5
|
+
# audio data for a Pattern (with the help of a Kit).
|
1
6
|
class Pattern
|
2
7
|
FLOW_TRACK_NAME = "flow"
|
3
8
|
|
data/lib/song.rb
CHANGED
@@ -1,5 +1,13 @@
|
|
1
1
|
class InvalidTempoError < RuntimeError; end
|
2
2
|
|
3
|
+
|
4
|
+
# Domain object which models the 'sheet music' for a full song. Models the Patterns
|
5
|
+
# that should be played, in which order (i.e. the flow), and at which tempo.
|
6
|
+
#
|
7
|
+
# This is the top-level model object that is used by the AudioEngine to produce
|
8
|
+
# actual audio data. A Song tells the AudioEngine what sounds to trigger and when.
|
9
|
+
# A Kit provides the sample data for each of these sounds. With a Song and a Kit
|
10
|
+
# the AudioEngine can produce the audio data that is saved to disk.
|
3
11
|
class Song
|
4
12
|
DEFAULT_TEMPO = 120
|
5
13
|
|
@@ -61,21 +69,6 @@ class Song
|
|
61
69
|
|
62
70
|
return copy
|
63
71
|
end
|
64
|
-
|
65
|
-
# Changes the song flow to consist of playing the specified pattern once. All other patterns will
|
66
|
-
# be removed from the song as a side effect.
|
67
|
-
#
|
68
|
-
# pattern_to_keep - The Symbol name of the pattern to preserve.
|
69
|
-
#
|
70
|
-
# Returns nothing.
|
71
|
-
def remove_patterns_except(pattern_to_keep)
|
72
|
-
unless @patterns.has_key?(pattern_to_keep)
|
73
|
-
raise StandardError, "The song does not include a pattern called #{pattern_to_keep}"
|
74
|
-
end
|
75
|
-
|
76
|
-
@flow = [pattern_to_keep]
|
77
|
-
remove_unused_patterns()
|
78
|
-
end
|
79
72
|
|
80
73
|
# Splits a Song object into multiple Song objects, where each new
|
81
74
|
# Song only has 1 track. For example, if a Song has 5 tracks, this will return
|
data/lib/songoptimizer.rb
CHANGED
@@ -109,16 +109,39 @@ protected
|
|
109
109
|
#
|
110
110
|
# Duplicate Patterns are more likely to occur after calling subdivide_song_patterns().
|
111
111
|
def prune_duplicate_patterns(song)
|
112
|
+
pattern_replacements = determine_pattern_replacements(song.patterns)
|
113
|
+
|
114
|
+
# Update flow to remove references to duplicates
|
115
|
+
new_flow = song.flow
|
116
|
+
pattern_replacements.each do |duplicate, replacement|
|
117
|
+
new_flow = new_flow.map do |pattern_name|
|
118
|
+
(pattern_name == duplicate) ? replacement : pattern_name
|
119
|
+
end
|
120
|
+
end
|
121
|
+
song.flow = new_flow
|
122
|
+
|
123
|
+
# This isn't strictly necessary, but makes resulting songs easier to read for debugging purposes.
|
124
|
+
song.remove_unused_patterns()
|
125
|
+
|
126
|
+
return song
|
127
|
+
end
|
128
|
+
|
129
|
+
|
130
|
+
# Examines a set of patterns definitions, determining which ones have the same tracks with the same
|
131
|
+
# rhythms. Then constructs a hash of pattern => pattern indicating that all occurances in the flow
|
132
|
+
# of the key should be replaced with the value, so that the other equivalent definitions can be pruned
|
133
|
+
# from the song (and hence their sample data doesn't need to be generated).
|
134
|
+
def determine_pattern_replacements(patterns)
|
112
135
|
seen_patterns = []
|
113
136
|
replacements = {}
|
114
137
|
|
115
138
|
# Pattern names are sorted to ensure predictable pattern replacement. Makes tests easier to write.
|
116
139
|
# Sort function added manually because Ruby 1.8 doesn't know how to sort symbols...
|
117
|
-
pattern_names =
|
140
|
+
pattern_names = patterns.keys.sort {|x, y| x.to_s <=> y.to_s }
|
118
141
|
|
119
142
|
# Detect duplicates
|
120
143
|
pattern_names.each do |pattern_name|
|
121
|
-
pattern =
|
144
|
+
pattern = patterns[pattern_name]
|
122
145
|
found_duplicate = false
|
123
146
|
seen_patterns.each do |seen_pattern|
|
124
147
|
if !found_duplicate && pattern.same_tracks_as?(seen_pattern)
|
@@ -131,20 +154,7 @@ protected
|
|
131
154
|
seen_patterns << pattern
|
132
155
|
end
|
133
156
|
end
|
134
|
-
|
135
|
-
# Update flow to remove references to duplicates
|
136
|
-
new_flow = song.flow
|
137
|
-
replacements.each do |duplicate, replacement|
|
138
|
-
new_flow = new_flow.map do |pattern_name|
|
139
|
-
(pattern_name == duplicate) ? replacement : pattern_name
|
140
|
-
end
|
141
|
-
end
|
142
|
-
song.flow = new_flow
|
143
|
-
|
144
|
-
# Remove unused Patterns. Not strictly necessary, but makes resulting songs
|
145
|
-
# easier to read for debugging purposes.
|
146
|
-
song.remove_unused_patterns()
|
147
157
|
|
148
|
-
return
|
158
|
+
return replacements
|
149
159
|
end
|
150
160
|
end
|
data/lib/songparser.rb
CHANGED
@@ -1,8 +1,12 @@
|
|
1
1
|
class SongParseError < RuntimeError; end
|
2
2
|
|
3
|
-
|
4
|
-
#
|
5
|
-
#
|
3
|
+
|
4
|
+
# This class is used to parse a raw YAML song definition into domain objects (i.e.
|
5
|
+
# Song, Pattern, Track, and Kit). These domain objects can then be used by AudioEngine
|
6
|
+
# to generate the actual audio data that is saved to disk.
|
7
|
+
#
|
8
|
+
# The sole public method is parse(). It takes a raw YAML string and returns a Song and
|
9
|
+
# Kit object (or raises an error if the YAML string couldn't be parsed correctly).
|
6
10
|
class SongParser
|
7
11
|
DONT_USE_STRUCTURE_WARNING =
|
8
12
|
"\n" +
|
@@ -19,17 +23,17 @@ class SongParser
|
|
19
23
|
- Verse: x2
|
20
24
|
- Chorus: x2"
|
21
25
|
|
22
|
-
def initialize
|
26
|
+
def initialize()
|
23
27
|
end
|
24
|
-
|
28
|
+
|
29
|
+
|
25
30
|
# Parses a raw YAML song definition and converts it into a Song and Kit object.
|
26
31
|
def parse(base_path, raw_yaml_string)
|
27
32
|
raw_song_components = hashify_raw_yaml(raw_yaml_string)
|
28
33
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
#end
|
34
|
+
unless raw_song_components[:folder] == nil
|
35
|
+
base_path = raw_song_components[:folder]
|
36
|
+
end
|
33
37
|
|
34
38
|
song = Song.new()
|
35
39
|
|
@@ -63,9 +67,11 @@ class SongParser
|
|
63
67
|
|
64
68
|
return song, kit
|
65
69
|
end
|
66
|
-
|
70
|
+
|
71
|
+
|
67
72
|
private
|
68
73
|
|
74
|
+
|
69
75
|
def hashify_raw_yaml(raw_yaml_string)
|
70
76
|
begin
|
71
77
|
raw_song_definition = YAML.load(raw_yaml_string)
|
@@ -74,7 +80,6 @@ private
|
|
74
80
|
end
|
75
81
|
|
76
82
|
raw_song_components = {}
|
77
|
-
warnings = []
|
78
83
|
raw_song_components[:full_definition] = downcase_hash_keys(raw_song_definition)
|
79
84
|
|
80
85
|
if raw_song_components[:full_definition]["song"] != nil
|
@@ -102,11 +107,13 @@ private
|
|
102
107
|
|
103
108
|
return raw_song_components
|
104
109
|
end
|
105
|
-
|
110
|
+
|
111
|
+
|
106
112
|
def build_kit(base_path, raw_kit, raw_patterns)
|
107
113
|
kit_items = {}
|
108
114
|
|
109
115
|
# Add sounds defined in the Kit section of the song header
|
116
|
+
# Converts [{a=>1}, {b=>2}, {c=>3}] from raw YAML to {a=>1, b=>2, c=>3}
|
110
117
|
# TODO: Raise error is same name is defined more than once in the Kit
|
111
118
|
unless raw_kit == nil
|
112
119
|
raw_kit.each do |kit_item|
|
@@ -136,7 +143,8 @@ private
|
|
136
143
|
kit = Kit.new(base_path, kit_items)
|
137
144
|
return kit
|
138
145
|
end
|
139
|
-
|
146
|
+
|
147
|
+
|
140
148
|
def add_patterns_to_song(song, raw_patterns)
|
141
149
|
raw_patterns.keys.each do |key|
|
142
150
|
new_pattern = song.pattern key.to_sym
|
@@ -145,7 +153,7 @@ private
|
|
145
153
|
# TODO Also raise error if only there is only 1 track and it's a flow track
|
146
154
|
if track_list == nil
|
147
155
|
# TODO: Use correct capitalization of pattern name in error message
|
148
|
-
# TODO: Possibly allow if pattern not referenced in the
|
156
|
+
# TODO: Possibly allow if pattern not referenced in the Flow, or has 0 repeats?
|
149
157
|
raise SongParseError, "Pattern '#{key}' has no tracks. It needs at least one."
|
150
158
|
end
|
151
159
|
|
@@ -160,7 +168,8 @@ private
|
|
160
168
|
end
|
161
169
|
end
|
162
170
|
end
|
163
|
-
|
171
|
+
|
172
|
+
|
164
173
|
def set_song_flow(song, raw_flow)
|
165
174
|
flow = []
|
166
175
|
|
@@ -195,7 +204,8 @@ private
|
|
195
204
|
}
|
196
205
|
song.flow = flow
|
197
206
|
end
|
198
|
-
|
207
|
+
|
208
|
+
|
199
209
|
# Converts all hash keys to be lowercase
|
200
210
|
def downcase_hash_keys(hash)
|
201
211
|
return hash.inject({}) do |new_hash, pair|
|
data/lib/track.rb
CHANGED
@@ -1,5 +1,11 @@
|
|
1
1
|
class InvalidRhythmError < RuntimeError; end
|
2
2
|
|
3
|
+
|
4
|
+
# Domain object which models a kit sound playing a rhythm. For example,
|
5
|
+
# a bass drum playing every quarter note for two measures.
|
6
|
+
#
|
7
|
+
# This object is like sheet music; the AudioEngine is responsible creating actual
|
8
|
+
# audio data for a Track (with the help of a Kit).
|
3
9
|
class Track
|
4
10
|
REST = "."
|
5
11
|
BEAT = "X"
|
@@ -27,7 +33,7 @@ class Track
|
|
27
33
|
elsif ch == REST
|
28
34
|
beat_length += 1
|
29
35
|
else
|
30
|
-
raise InvalidRhythmError, "Track #{@name} has an invalid rhythm: '#{rhythm}'. Can only contain '
|
36
|
+
raise InvalidRhythmError, "Track #{@name} has an invalid rhythm: '#{rhythm}'. Can only contain '#{BEAT}', '#{REST}' or '#{BARLINE}'"
|
31
37
|
end
|
32
38
|
end
|
33
39
|
|
data/test/audioengine_test.rb
CHANGED
data/test/audioutils_test.rb
CHANGED
data/test/includes.rb
CHANGED
@@ -2,12 +2,15 @@
|
|
2
2
|
require 'test/unit'
|
3
3
|
require 'yaml'
|
4
4
|
require 'rubygems'
|
5
|
+
|
6
|
+
# External gems
|
5
7
|
require 'wavefile'
|
6
8
|
|
7
9
|
# BEATS classes
|
8
10
|
require 'audioengine'
|
9
11
|
require 'audioutils'
|
10
12
|
require 'beats'
|
13
|
+
require 'beatswavefile'
|
11
14
|
require 'kit'
|
12
15
|
require 'pattern'
|
13
16
|
require 'patternexpander'
|
@@ -15,4 +18,3 @@ require 'song'
|
|
15
18
|
require 'songparser'
|
16
19
|
require 'songoptimizer'
|
17
20
|
require 'track'
|
18
|
-
require 'beatswavefile'
|
@@ -1,8 +1,8 @@
|
|
1
1
|
$:.unshift File.join(File.dirname(__FILE__),'..','lib')
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'includes'
|
4
4
|
|
5
|
-
class
|
5
|
+
class IntegrationTest < Test::Unit::TestCase
|
6
6
|
TRACK_NAMES = ["bass", "snare", "hh_closed", "hh_closed2", "agogo", "tom4", "tom2"]
|
7
7
|
OUTPUT_FOLDER = "test/integration_output"
|
8
8
|
|
@@ -48,7 +48,11 @@ class SongParserTest < Test::Unit::TestCase
|
|
48
48
|
beats = Beats.new(song_fixture, actual_output_file, {:split => false, :pattern => nil})
|
49
49
|
beats.run()
|
50
50
|
assert(File.exists?(actual_output_file), "Expected file '#{actual_output_file}' to exist, but it doesn't.")
|
51
|
-
|
51
|
+
|
52
|
+
# Reading the files this way instead of a plain File.read() for Windows compatibility with binary files
|
53
|
+
expected_output_file_contents = File.open(expected_output_file, "rb") {|f| f.read() }
|
54
|
+
actual_output_file_contents = File.open(actual_output_file, "rb") {|f| f.read() }
|
55
|
+
assert_equal(expected_output_file_contents, actual_output_file_contents)
|
52
56
|
|
53
57
|
# Clean up after ourselves
|
54
58
|
File.delete(actual_output_file)
|
@@ -81,14 +85,21 @@ class SongParserTest < Test::Unit::TestCase
|
|
81
85
|
actual_output_file = "#{actual_output_prefix}-#{track_name}.wav"
|
82
86
|
expected_output_file = "#{expected_output_prefix}-#{track_name}.wav"
|
83
87
|
assert(File.exists?(actual_output_file), "Expected file '#{actual_output_file}' to exist, but it doesn't.")
|
84
|
-
assert_equal(File.read(expected_output_file), File.read(actual_output_file))
|
85
88
|
|
89
|
+
# Reading the files this way instead of a plain File.read() for Windows compatibility with binary files
|
90
|
+
expected_output_file_contents = File.open(expected_output_file, "rb") {|f| f.read() }
|
91
|
+
actual_output_file_contents = File.open(actual_output_file, "rb") {|f| f.read() }
|
92
|
+
assert_equal(expected_output_file_contents, actual_output_file_contents)
|
93
|
+
|
86
94
|
# Clean up after ourselves
|
87
95
|
File.delete(actual_output_file)
|
88
96
|
end
|
89
97
|
end
|
90
98
|
|
91
99
|
def clean_output_folder()
|
100
|
+
# Make the folder if it doesn't already exist
|
101
|
+
Dir.mkdir(OUTPUT_FOLDER) unless File.exists?(OUTPUT_FOLDER)
|
102
|
+
|
92
103
|
dir = Dir.new(OUTPUT_FOLDER)
|
93
104
|
file_names = dir.entries
|
94
105
|
file_names.each do |file_name|
|
@@ -97,4 +108,4 @@ class SongParserTest < Test::Unit::TestCase
|
|
97
108
|
end
|
98
109
|
end
|
99
110
|
end
|
100
|
-
end
|
111
|
+
end
|
data/test/kit_test.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
$:.unshift File.join(File.dirname(__FILE__),'..','lib')
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'includes'
|
4
4
|
|
5
5
|
# Kit which allows directly changing the sound bank after initialization, to allows tests
|
6
6
|
# to use fixture data directly in the test instead of loading it from the file system.
|
data/test/pattern_test.rb
CHANGED
data/test/song_test.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
$:.unshift File.join(File.dirname(__FILE__),'..','lib')
|
2
2
|
|
3
|
-
require '
|
3
|
+
require 'includes'
|
4
4
|
|
5
5
|
class SongTest < Test::Unit::TestCase
|
6
6
|
FIXTURES = [:repeats_not_specified,
|
@@ -47,6 +47,23 @@ class SongTest < Test::Unit::TestCase
|
|
47
47
|
assert_equal([:verse, :chorus, :verse, :chorus, :chorus], test_songs[:from_code].flow)
|
48
48
|
end
|
49
49
|
|
50
|
+
def test_pattern
|
51
|
+
song = Song.new()
|
52
|
+
verse1 = song.pattern :Verse
|
53
|
+
|
54
|
+
assert_equal(:Verse, verse1.name)
|
55
|
+
assert_equal({:Verse => verse1}, song.patterns)
|
56
|
+
|
57
|
+
verse2 = song.pattern :Verse
|
58
|
+
assert_equal(:Verse, verse2.name)
|
59
|
+
assert_equal({:Verse => verse2}, song.patterns)
|
60
|
+
assert_not_equal(verse1, verse2)
|
61
|
+
|
62
|
+
chorus = song.pattern :Chorus
|
63
|
+
assert_equal(2, song.patterns.length)
|
64
|
+
assert_equal({:Chorus => chorus, :Verse => verse2}, song.patterns)
|
65
|
+
end
|
66
|
+
|
50
67
|
def test_total_tracks
|
51
68
|
test_songs = generate_test_data()
|
52
69
|
|
@@ -93,20 +110,6 @@ class SongTest < Test::Unit::TestCase
|
|
93
110
|
assert_equal({}, cloned_song.patterns)
|
94
111
|
end
|
95
112
|
|
96
|
-
def test_remove_patterns_except
|
97
|
-
# Remove an existing pattern.
|
98
|
-
test_songs = generate_test_data()
|
99
|
-
test_songs[:example_with_kit].remove_patterns_except(:chorus)
|
100
|
-
assert_equal([:chorus], test_songs[:example_with_kit].flow)
|
101
|
-
assert_equal([:chorus], test_songs[:example_with_kit].patterns.keys)
|
102
|
-
|
103
|
-
# Try to remove a non-existent pattern. Error city.
|
104
|
-
test_songs = generate_test_data()
|
105
|
-
assert_raise(StandardError) do
|
106
|
-
test_songs[:example_with_kit].remove_patterns_except(:iamnotapattern)
|
107
|
-
end
|
108
|
-
end
|
109
|
-
|
110
113
|
def test_split
|
111
114
|
test_songs = generate_test_data()
|
112
115
|
split_songs = test_songs[:example_with_kit].split()
|
data/test/songoptimizer_test.rb
CHANGED
data/test/songparser_test.rb
CHANGED
data/test/track_test.rb
CHANGED
metadata
CHANGED
@@ -1,13 +1,8 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: beats
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
hash: 29
|
5
4
|
prerelease:
|
6
|
-
|
7
|
-
- 1
|
8
|
-
- 2
|
9
|
-
- 1
|
10
|
-
version: 1.2.1
|
5
|
+
version: 1.2.2
|
11
6
|
platform: ruby
|
12
7
|
authors:
|
13
8
|
- Joel Strait
|
@@ -15,12 +10,21 @@ autorequire:
|
|
15
10
|
bindir: bin
|
16
11
|
cert_chain: []
|
17
12
|
|
18
|
-
date: 2011-
|
19
|
-
|
20
|
-
|
21
|
-
|
13
|
+
date: 2011-06-13 00:00:00 Z
|
14
|
+
dependencies:
|
15
|
+
- !ruby/object:Gem::Dependency
|
16
|
+
name: wavefile
|
17
|
+
prerelease: false
|
18
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
19
|
+
none: false
|
20
|
+
requirements:
|
21
|
+
- - "="
|
22
|
+
- !ruby/object:Gem::Version
|
23
|
+
version: 0.3.0
|
24
|
+
type: :runtime
|
25
|
+
version_requirements: *id001
|
22
26
|
description: A command-line drum machine. Feed it a song notated in YAML, and it will produce a precision-milled Wave file of impeccable timing and feel.
|
23
|
-
email:
|
27
|
+
email: joel dot strait at Google's popular web mail service
|
24
28
|
executables:
|
25
29
|
- beats
|
26
30
|
extensions: []
|
@@ -30,6 +34,7 @@ extra_rdoc_files: []
|
|
30
34
|
files:
|
31
35
|
- LICENSE
|
32
36
|
- README.markdown
|
37
|
+
- Rakefile
|
33
38
|
- lib/audioengine.rb
|
34
39
|
- lib/audioutils.rb
|
35
40
|
- lib/beats.rb
|
@@ -41,7 +46,6 @@ files:
|
|
41
46
|
- lib/songoptimizer.rb
|
42
47
|
- lib/songparser.rb
|
43
48
|
- lib/track.rb
|
44
|
-
- lib/wavefile.rb
|
45
49
|
- bin/beats
|
46
50
|
- test/audioengine_test.rb
|
47
51
|
- test/audioutils_test.rb
|
@@ -104,7 +108,7 @@ files:
|
|
104
108
|
- test/fixtures/valid/with_structure.txt
|
105
109
|
- test/fixtures/yaml/song_yaml.txt
|
106
110
|
- test/includes.rb
|
107
|
-
- test/
|
111
|
+
- test/integration_test.rb
|
108
112
|
- test/kit_test.rb
|
109
113
|
- test/pattern_test.rb
|
110
114
|
- test/patternexpander_test.rb
|
@@ -198,7 +202,6 @@ files:
|
|
198
202
|
- test/sounds/tom4_stereo_8.wav
|
199
203
|
- test/sounds/tone.wav
|
200
204
|
- test/track_test.rb
|
201
|
-
has_rdoc: true
|
202
205
|
homepage: http://beatsdrummachine.com/
|
203
206
|
licenses: []
|
204
207
|
|
@@ -212,23 +215,17 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
212
215
|
requirements:
|
213
216
|
- - ">="
|
214
217
|
- !ruby/object:Gem::Version
|
215
|
-
hash: 3
|
216
|
-
segments:
|
217
|
-
- 0
|
218
218
|
version: "0"
|
219
219
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
220
220
|
none: false
|
221
221
|
requirements:
|
222
222
|
- - ">="
|
223
223
|
- !ruby/object:Gem::Version
|
224
|
-
hash: 3
|
225
|
-
segments:
|
226
|
-
- 0
|
227
224
|
version: "0"
|
228
225
|
requirements: []
|
229
226
|
|
230
227
|
rubyforge_project:
|
231
|
-
rubygems_version: 1.
|
228
|
+
rubygems_version: 1.8.5
|
232
229
|
signing_key:
|
233
230
|
specification_version: 3
|
234
231
|
summary: A command-line drum machine. Feed it a song notated in YAML, and it will produce a precision-milled Wave file of impeccable timing and feel.
|
@@ -294,7 +291,7 @@ test_files:
|
|
294
291
|
- test/fixtures/valid/with_structure.txt
|
295
292
|
- test/fixtures/yaml/song_yaml.txt
|
296
293
|
- test/includes.rb
|
297
|
-
- test/
|
294
|
+
- test/integration_test.rb
|
298
295
|
- test/kit_test.rb
|
299
296
|
- test/pattern_test.rb
|
300
297
|
- test/patternexpander_test.rb
|
data/lib/wavefile.rb
DELETED
@@ -1,475 +0,0 @@
|
|
1
|
-
# DO NOT EDIT THIS FILE
|
2
|
-
# DO NOT EDIT THIS FILE
|
3
|
-
# DO NOT EDIT THIS FILE
|
4
|
-
#
|
5
|
-
# OK, so what's the deal here? This file contains the WaveFile class defined
|
6
|
-
# in v0.3.0 of the WaveFile gem (http://github.com/jstrait/wavefile). So why
|
7
|
-
# are we manually importing the class instead of just using the Gem? The
|
8
|
-
# reason is that (on my machine at least) it takes about 0.2 seconds
|
9
|
-
# to load RubyGems in 1.8.7. This is a non-trivial amount of time, and for
|
10
|
-
# shorter songs it can be a relatively large percentage of the total runtime.
|
11
|
-
# (In Ruby 1.9, it has no effect on performance, since RubyGems is already
|
12
|
-
# baked in anyway).
|
13
|
-
#
|
14
|
-
# So, considering that the WaveFile gem only contains one class (this file),
|
15
|
-
# I'm just moving it here. This means BEATS doesn't have to use the
|
16
|
-
# WaveFile gem, it therefore doesn't need to use RubyGems either. It's a hack,
|
17
|
-
# but a pragmatic hack.
|
18
|
-
#
|
19
|
-
# The caveat is that to make this as Gem-like as possible, this file should
|
20
|
-
# be treated as an external library, and not edited.
|
21
|
-
|
22
|
-
=begin
|
23
|
-
WAV File Specification
|
24
|
-
FROM http://ccrma.stanford.edu/courses/422/projects/WaveFormat/
|
25
|
-
The canonical WAVE format starts with the RIFF header:
|
26
|
-
0 4 ChunkID Contains the letters "RIFF" in ASCII form
|
27
|
-
(0x52494646 big-endian form).
|
28
|
-
4 4 ChunkSize 36 + SubChunk2Size, or more precisely:
|
29
|
-
4 + (8 + SubChunk1Size) + (8 + SubChunk2Size)
|
30
|
-
This is the size of the rest of the chunk
|
31
|
-
following this number. This is the size of the
|
32
|
-
entire file in bytes minus 8 bytes for the
|
33
|
-
two fields not included in this count:
|
34
|
-
ChunkID and ChunkSize.
|
35
|
-
8 4 Format Contains the letters "WAVE"
|
36
|
-
(0x57415645 big-endian form).
|
37
|
-
|
38
|
-
The "WAVE" format consists of two subchunks: "fmt " and "data":
|
39
|
-
The "fmt " subchunk describes the sound data's format:
|
40
|
-
12 4 Subchunk1ID Contains the letters "fmt "
|
41
|
-
(0x666d7420 big-endian form).
|
42
|
-
16 4 Subchunk1Size 16 for PCM. This is the size of the
|
43
|
-
rest of the Subchunk which follows this number.
|
44
|
-
20 2 AudioFormat PCM = 1 (i.e. Linear quantization)
|
45
|
-
Values other than 1 indicate some
|
46
|
-
form of compression.
|
47
|
-
22 2 NumChannels Mono = 1, Stereo = 2, etc.
|
48
|
-
24 4 SampleRate 8000, 44100, etc.
|
49
|
-
28 4 ByteRate == SampleRate * NumChannels * BitsPerSample/8
|
50
|
-
32 2 BlockAlign == NumChannels * BitsPerSample/8
|
51
|
-
The number of bytes for one sample including
|
52
|
-
all channels. I wonder what happens when
|
53
|
-
this number isn't an integer?
|
54
|
-
34 2 BitsPerSample 8 bits = 8, 16 bits = 16, etc.
|
55
|
-
|
56
|
-
The "data" subchunk contains the size of the data and the actual sound:
|
57
|
-
36 4 Subchunk2ID Contains the letters "data"
|
58
|
-
(0x64617461 big-endian form).
|
59
|
-
40 4 Subchunk2Size == NumSamples * NumChannels * BitsPerSample/8
|
60
|
-
This is the number of bytes in the data.
|
61
|
-
You can also think of this as the size
|
62
|
-
of the read of the subchunk following this
|
63
|
-
number.
|
64
|
-
44 * Data The actual sound data.
|
65
|
-
=end
|
66
|
-
|
67
|
-
class WaveFile
|
68
|
-
CHUNK_ID = "RIFF"
|
69
|
-
FORMAT = "WAVE"
|
70
|
-
FORMAT_CHUNK_ID = "fmt "
|
71
|
-
SUB_CHUNK1_SIZE = 16
|
72
|
-
PCM = 1
|
73
|
-
DATA_CHUNK_ID = "data"
|
74
|
-
HEADER_SIZE = 36
|
75
|
-
|
76
|
-
def initialize(num_channels, sample_rate, bits_per_sample, sample_data = [])
|
77
|
-
if num_channels == :mono
|
78
|
-
@num_channels = 1
|
79
|
-
elsif num_channels == :stereo
|
80
|
-
@num_channels = 2
|
81
|
-
else
|
82
|
-
@num_channels = num_channels
|
83
|
-
end
|
84
|
-
@sample_rate = sample_rate
|
85
|
-
@bits_per_sample = bits_per_sample
|
86
|
-
@sample_data = sample_data
|
87
|
-
|
88
|
-
@byte_rate = sample_rate * @num_channels * (bits_per_sample / 8)
|
89
|
-
@block_align = @num_channels * (bits_per_sample / 8)
|
90
|
-
end
|
91
|
-
|
92
|
-
def self.open(path)
|
93
|
-
file = File.open(path, "rb")
|
94
|
-
|
95
|
-
begin
|
96
|
-
header = read_header(file)
|
97
|
-
errors = validate_header(header)
|
98
|
-
|
99
|
-
if errors == []
|
100
|
-
sample_data = read_sample_data(file,
|
101
|
-
header[:num_channels],
|
102
|
-
header[:bits_per_sample],
|
103
|
-
header[:sub_chunk2_size])
|
104
|
-
|
105
|
-
wave_file = self.new(header[:num_channels],
|
106
|
-
header[:sample_rate],
|
107
|
-
header[:bits_per_sample],
|
108
|
-
sample_data)
|
109
|
-
else
|
110
|
-
error_msg = "#{path} can't be opened, due to the following errors:\n"
|
111
|
-
errors.each {|error| error_msg += " * #{error}\n" }
|
112
|
-
raise StandardError, error_msg
|
113
|
-
end
|
114
|
-
rescue EOFError
|
115
|
-
raise StandardError, "An error occured while reading #{path}."
|
116
|
-
ensure
|
117
|
-
file.close()
|
118
|
-
end
|
119
|
-
|
120
|
-
return wave_file
|
121
|
-
end
|
122
|
-
|
123
|
-
def save(path)
|
124
|
-
# All numeric values should be saved in little-endian format
|
125
|
-
|
126
|
-
sample_data_size = @sample_data.length * @num_channels * (@bits_per_sample / 8)
|
127
|
-
|
128
|
-
# Write the header
|
129
|
-
file_contents = CHUNK_ID
|
130
|
-
file_contents += [HEADER_SIZE + sample_data_size].pack("V")
|
131
|
-
file_contents += FORMAT
|
132
|
-
file_contents += FORMAT_CHUNK_ID
|
133
|
-
file_contents += [SUB_CHUNK1_SIZE].pack("V")
|
134
|
-
file_contents += [PCM].pack("v")
|
135
|
-
file_contents += [@num_channels].pack("v")
|
136
|
-
file_contents += [@sample_rate].pack("V")
|
137
|
-
file_contents += [@byte_rate].pack("V")
|
138
|
-
file_contents += [@block_align].pack("v")
|
139
|
-
file_contents += [@bits_per_sample].pack("v")
|
140
|
-
file_contents += DATA_CHUNK_ID
|
141
|
-
file_contents += [sample_data_size].pack("V")
|
142
|
-
|
143
|
-
# Write the sample data
|
144
|
-
if !mono?
|
145
|
-
output_sample_data = []
|
146
|
-
@sample_data.each{|sample|
|
147
|
-
sample.each{|sub_sample|
|
148
|
-
output_sample_data << sub_sample
|
149
|
-
}
|
150
|
-
}
|
151
|
-
else
|
152
|
-
output_sample_data = @sample_data
|
153
|
-
end
|
154
|
-
|
155
|
-
if @bits_per_sample == 8
|
156
|
-
file_contents += output_sample_data.pack("C*")
|
157
|
-
elsif @bits_per_sample == 16
|
158
|
-
file_contents += output_sample_data.pack("s*")
|
159
|
-
else
|
160
|
-
raise StandardError, "Bits per sample is #{@bits_per_samples}, only 8 or 16 are supported"
|
161
|
-
end
|
162
|
-
|
163
|
-
file = File.open(path, "w")
|
164
|
-
file.syswrite(file_contents)
|
165
|
-
file.close
|
166
|
-
end
|
167
|
-
|
168
|
-
def sample_data()
|
169
|
-
return @sample_data
|
170
|
-
end
|
171
|
-
|
172
|
-
def normalized_sample_data()
|
173
|
-
if @bits_per_sample == 8
|
174
|
-
min_value = 128.0
|
175
|
-
max_value = 127.0
|
176
|
-
midpoint = 128
|
177
|
-
elsif @bits_per_sample == 16
|
178
|
-
min_value = 32768.0
|
179
|
-
max_value = 32767.0
|
180
|
-
midpoint = 0
|
181
|
-
else
|
182
|
-
raise StandardError, "Bits per sample is #{@bits_per_samples}, only 8 or 16 are supported"
|
183
|
-
end
|
184
|
-
|
185
|
-
if mono?
|
186
|
-
normalized_sample_data = @sample_data.map {|sample|
|
187
|
-
sample -= midpoint
|
188
|
-
if sample < 0
|
189
|
-
sample.to_f / min_value
|
190
|
-
else
|
191
|
-
sample.to_f / max_value
|
192
|
-
end
|
193
|
-
}
|
194
|
-
else
|
195
|
-
normalized_sample_data = @sample_data.map {|sample|
|
196
|
-
sample.map {|sub_sample|
|
197
|
-
sub_sample -= midpoint
|
198
|
-
if sub_sample < 0
|
199
|
-
sub_sample.to_f / min_value
|
200
|
-
else
|
201
|
-
sub_sample.to_f / max_value
|
202
|
-
end
|
203
|
-
}
|
204
|
-
}
|
205
|
-
end
|
206
|
-
|
207
|
-
return normalized_sample_data
|
208
|
-
end
|
209
|
-
|
210
|
-
def sample_data=(sample_data)
|
211
|
-
if sample_data.length > 0 && ((mono? && sample_data[0].class == Float) ||
|
212
|
-
(!mono? && sample_data[0][0].class == Float))
|
213
|
-
if @bits_per_sample == 8
|
214
|
-
# Samples in 8-bit wave files are stored as a unsigned byte
|
215
|
-
# Effective values are 0 to 255, midpoint at 128
|
216
|
-
min_value = 128.0
|
217
|
-
max_value = 127.0
|
218
|
-
midpoint = 128
|
219
|
-
elsif @bits_per_sample == 16
|
220
|
-
# Samples in 16-bit wave files are stored as a signed little-endian short
|
221
|
-
# Effective values are -32768 to 32767, midpoint at 0
|
222
|
-
min_value = 32768.0
|
223
|
-
max_value = 32767.0
|
224
|
-
midpoint = 0
|
225
|
-
else
|
226
|
-
raise StandardError, "Bits per sample is #{@bits_per_samples}, only 8 or 16 are supported"
|
227
|
-
end
|
228
|
-
|
229
|
-
if mono?
|
230
|
-
@sample_data = sample_data.map {|sample|
|
231
|
-
if(sample < 0.0)
|
232
|
-
(sample * min_value).round + midpoint
|
233
|
-
else
|
234
|
-
(sample * max_value).round + midpoint
|
235
|
-
end
|
236
|
-
}
|
237
|
-
else
|
238
|
-
@sample_data = sample_data.map {|sample|
|
239
|
-
sample.map {|sub_sample|
|
240
|
-
if(sub_sample < 0.0)
|
241
|
-
(sub_sample * min_value).round + midpoint
|
242
|
-
else
|
243
|
-
(sub_sample * max_value).round + midpoint
|
244
|
-
end
|
245
|
-
}
|
246
|
-
}
|
247
|
-
end
|
248
|
-
else
|
249
|
-
@sample_data = sample_data
|
250
|
-
end
|
251
|
-
end
|
252
|
-
|
253
|
-
def mono?()
|
254
|
-
return num_channels == 1
|
255
|
-
end
|
256
|
-
|
257
|
-
def stereo?()
|
258
|
-
return num_channels == 2
|
259
|
-
end
|
260
|
-
|
261
|
-
def reverse()
|
262
|
-
sample_data.reverse!()
|
263
|
-
end
|
264
|
-
|
265
|
-
def duration()
|
266
|
-
total_samples = sample_data.length
|
267
|
-
samples_per_millisecond = @sample_rate / 1000.0
|
268
|
-
samples_per_second = @sample_rate
|
269
|
-
samples_per_minute = samples_per_second * 60
|
270
|
-
samples_per_hour = samples_per_minute * 60
|
271
|
-
hours, minutes, seconds, milliseconds = 0, 0, 0, 0
|
272
|
-
|
273
|
-
if(total_samples >= samples_per_hour)
|
274
|
-
hours = total_samples / samples_per_hour
|
275
|
-
total_samples -= samples_per_hour * hours
|
276
|
-
end
|
277
|
-
|
278
|
-
if(total_samples >= samples_per_minute)
|
279
|
-
minutes = total_samples / samples_per_minute
|
280
|
-
total_samples -= samples_per_minute * minutes
|
281
|
-
end
|
282
|
-
|
283
|
-
if(total_samples >= samples_per_second)
|
284
|
-
seconds = total_samples / samples_per_second
|
285
|
-
total_samples -= samples_per_second * seconds
|
286
|
-
end
|
287
|
-
|
288
|
-
milliseconds = (total_samples / samples_per_millisecond).floor
|
289
|
-
|
290
|
-
return { :hours => hours, :minutes => minutes, :seconds => seconds, :milliseconds => milliseconds }
|
291
|
-
end
|
292
|
-
|
293
|
-
def bits_per_sample=(new_bits_per_sample)
|
294
|
-
if new_bits_per_sample != 8 && new_bits_per_sample != 16
|
295
|
-
raise StandardError, "Bits per sample of #{@bits_per_samples} is invalid, only 8 or 16 are supported"
|
296
|
-
end
|
297
|
-
|
298
|
-
if @bits_per_sample == 16 && new_bits_per_sample == 8
|
299
|
-
conversion_func = lambda {|sample|
|
300
|
-
if(sample < 0)
|
301
|
-
(sample / 256) + 128
|
302
|
-
else
|
303
|
-
# Faster to just divide by integer 258?
|
304
|
-
(sample / 258.007874015748031).round + 128
|
305
|
-
end
|
306
|
-
}
|
307
|
-
|
308
|
-
if mono?
|
309
|
-
@sample_data.map! &conversion_func
|
310
|
-
else
|
311
|
-
sample_data.map! {|sample| sample.map! &conversion_func }
|
312
|
-
end
|
313
|
-
elsif @bits_per_sample == 8 && new_bits_per_sample == 16
|
314
|
-
conversion_func = lambda {|sample|
|
315
|
-
sample -= 128
|
316
|
-
if(sample < 0)
|
317
|
-
sample * 256
|
318
|
-
else
|
319
|
-
# Faster to just multiply by integer 258?
|
320
|
-
(sample * 258.007874015748031).round
|
321
|
-
end
|
322
|
-
}
|
323
|
-
|
324
|
-
if mono?
|
325
|
-
@sample_data.map! &conversion_func
|
326
|
-
else
|
327
|
-
sample_data.map! {|sample| sample.map! &conversion_func }
|
328
|
-
end
|
329
|
-
end
|
330
|
-
|
331
|
-
@bits_per_sample = new_bits_per_sample
|
332
|
-
end
|
333
|
-
|
334
|
-
def num_channels=(new_num_channels)
|
335
|
-
if new_num_channels == :mono
|
336
|
-
new_num_channels = 1
|
337
|
-
elsif new_num_channels == :stereo
|
338
|
-
new_num_channels = 2
|
339
|
-
end
|
340
|
-
|
341
|
-
# The cases of mono -> stereo and vice-versa are handled in specially,
|
342
|
-
# because those conversion methods are faster than the general methods,
|
343
|
-
# and the large majority of wave files are expected to be either mono or stereo.
|
344
|
-
if @num_channels == 1 && new_num_channels == 2
|
345
|
-
sample_data.map! {|sample| [sample, sample]}
|
346
|
-
elsif @num_channels == 2 && new_num_channels == 1
|
347
|
-
sample_data.map! {|sample| (sample[0] + sample[1]) / 2}
|
348
|
-
elsif @num_channels == 1 && new_num_channels >= 2
|
349
|
-
sample_data.map! {|sample| [].fill(sample, 0, new_num_channels)}
|
350
|
-
elsif @num_channels >= 2 && new_num_channels == 1
|
351
|
-
sample_data.map! {|sample| sample.inject(0) {|sub_sample, sum| sum + sub_sample } / @num_channels }
|
352
|
-
elsif @num_channels > 2 && new_num_channels == 2
|
353
|
-
sample_data.map! {|sample| [sample[0], sample[1]]}
|
354
|
-
end
|
355
|
-
|
356
|
-
@num_channels = new_num_channels
|
357
|
-
end
|
358
|
-
|
359
|
-
def inspect()
|
360
|
-
duration = self.duration()
|
361
|
-
|
362
|
-
result = "Channels: #{@num_channels}\n" +
|
363
|
-
"Sample rate: #{@sample_rate}\n" +
|
364
|
-
"Bits per sample: #{@bits_per_sample}\n" +
|
365
|
-
"Block align: #{@block_align}\n" +
|
366
|
-
"Byte rate: #{@byte_rate}\n" +
|
367
|
-
"Sample count: #{@sample_data.length}\n" +
|
368
|
-
"Duration: #{duration[:hours]}h:#{duration[:minutes]}m:#{duration[:seconds]}s:#{duration[:milliseconds]}ms\n"
|
369
|
-
end
|
370
|
-
|
371
|
-
attr_reader :num_channels, :bits_per_sample, :byte_rate, :block_align
|
372
|
-
attr_accessor :sample_rate
|
373
|
-
|
374
|
-
private
|
375
|
-
|
376
|
-
def self.read_header(file)
|
377
|
-
header = {}
|
378
|
-
|
379
|
-
# Read RIFF header
|
380
|
-
riff_header = file.sysread(12).unpack("a4Va4")
|
381
|
-
header[:chunk_id] = riff_header[0]
|
382
|
-
header[:chunk_size] = riff_header[1]
|
383
|
-
header[:format] = riff_header[2]
|
384
|
-
|
385
|
-
# Read format subchunk
|
386
|
-
header[:sub_chunk1_id], header[:sub_chunk1_size] = self.read_to_chunk(file, FORMAT_CHUNK_ID)
|
387
|
-
format_subchunk_str = file.sysread(header[:sub_chunk1_size])
|
388
|
-
format_subchunk = format_subchunk_str.unpack("vvVVvv") # Any extra parameters are ignored
|
389
|
-
header[:audio_format] = format_subchunk[0]
|
390
|
-
header[:num_channels] = format_subchunk[1]
|
391
|
-
header[:sample_rate] = format_subchunk[2]
|
392
|
-
header[:byte_rate] = format_subchunk[3]
|
393
|
-
header[:block_align] = format_subchunk[4]
|
394
|
-
header[:bits_per_sample] = format_subchunk[5]
|
395
|
-
|
396
|
-
# Read data subchunk
|
397
|
-
header[:sub_chunk2_id], header[:sub_chunk2_size] = self.read_to_chunk(file, DATA_CHUNK_ID)
|
398
|
-
|
399
|
-
return header
|
400
|
-
end
|
401
|
-
|
402
|
-
def self.read_to_chunk(file, expected_chunk_id)
|
403
|
-
chunk_id = file.sysread(4)
|
404
|
-
chunk_size = file.sysread(4).unpack("V")[0]
|
405
|
-
|
406
|
-
while chunk_id != expected_chunk_id
|
407
|
-
# Skip chunk
|
408
|
-
file.sysread(chunk_size)
|
409
|
-
|
410
|
-
chunk_id = file.sysread(4)
|
411
|
-
chunk_size = file.sysread(4).unpack("V")[0]
|
412
|
-
end
|
413
|
-
|
414
|
-
return chunk_id, chunk_size
|
415
|
-
end
|
416
|
-
|
417
|
-
def self.validate_header(header)
|
418
|
-
errors = []
|
419
|
-
|
420
|
-
unless header[:bits_per_sample] == 8 || header[:bits_per_sample] == 16
|
421
|
-
errors << "Invalid bits per sample of #{header[:bits_per_sample]}. Only 8 and 16 are supported."
|
422
|
-
end
|
423
|
-
|
424
|
-
unless (1..65535) === header[:num_channels]
|
425
|
-
errors << "Invalid number of channels. Must be between 1 and 65535."
|
426
|
-
end
|
427
|
-
|
428
|
-
unless header[:chunk_id] == CHUNK_ID
|
429
|
-
errors << "Unsupported chunk ID: '#{header[:chunk_id]}'"
|
430
|
-
end
|
431
|
-
|
432
|
-
unless header[:format] == FORMAT
|
433
|
-
errors << "Unsupported format: '#{header[:format]}'"
|
434
|
-
end
|
435
|
-
|
436
|
-
unless header[:sub_chunk1_id] == FORMAT_CHUNK_ID
|
437
|
-
errors << "Unsupported chunk id: '#{header[:sub_chunk1_id]}'"
|
438
|
-
end
|
439
|
-
|
440
|
-
unless header[:audio_format] == PCM
|
441
|
-
errors << "Unsupported audio format code: '#{header[:audio_format]}'"
|
442
|
-
end
|
443
|
-
|
444
|
-
unless header[:sub_chunk2_id] == DATA_CHUNK_ID
|
445
|
-
errors << "Unsupported chunk id: '#{header[:sub_chunk2_id]}'"
|
446
|
-
end
|
447
|
-
|
448
|
-
return errors
|
449
|
-
end
|
450
|
-
|
451
|
-
# Assumes that file is "queued up" to the first sample
|
452
|
-
def self.read_sample_data(file, num_channels, bits_per_sample, sample_data_size)
|
453
|
-
if(bits_per_sample == 8)
|
454
|
-
data = file.sysread(sample_data_size).unpack("C*")
|
455
|
-
elsif(bits_per_sample == 16)
|
456
|
-
data = file.sysread(sample_data_size).unpack("s*")
|
457
|
-
else
|
458
|
-
data = []
|
459
|
-
end
|
460
|
-
|
461
|
-
if(num_channels > 1)
|
462
|
-
multichannel_data = []
|
463
|
-
|
464
|
-
i = 0
|
465
|
-
while i < data.length
|
466
|
-
multichannel_data << data[i...(num_channels + i)]
|
467
|
-
i += num_channels
|
468
|
-
end
|
469
|
-
|
470
|
-
data = multichannel_data
|
471
|
-
end
|
472
|
-
|
473
|
-
return data
|
474
|
-
end
|
475
|
-
end
|