beats 1.2.1 → 1.2.2
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 +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
|