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 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
- Structure:
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.1, released on March 6, 2011. This is a minor release which includes the following improvments:
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
- * You can use the | character to represent bar lines in a track rhythm. This is optional, but often makes longer rhythms easier to read.
41
- * The "Structure" section of the song header is now called "Flow". (You can still use "Structure" for now, but you'll get a warning).
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.1) from [rubygems.org](http://rubygems.org/gems/beats), run the following from the command line:
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
- sudo gem install beats
49
+ gem install beats
52
50
 
53
- You can then run BEATS from the command-line using the `beats` command.
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
- 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).
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
@@ -0,0 +1,6 @@
1
+ require 'rake/testtask'
2
+
3
+ Rake::TestTask.new do |t|
4
+ t.libs << 'lib' << 'test'
5
+ t.pattern = 'test/**/*_test.rb'
6
+ end
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 "lib/wavefile"
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
- def parse_options
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
- optparse.parse!
46
+
47
+ USAGE_INSTRUCTIONS << optparse.to_s
48
+ optparse.parse!()
44
49
 
45
50
  return options
46
51
  end
47
52
 
48
- options = parse_options
49
- input_file_name = ARGV[0]
50
- output_file_name = ARGV[1]
51
-
52
- beats = Beats.new(input_file_name, output_file_name, options)
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 Errno::ENOENT => detail
59
- puts "\n"
60
- puts "Song file '#{input_file_name}' not found.\n"
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 sound data for the performance.
2
- # Applies a Kit to a Song (which contains sub Patterns and Tracks) to
3
- # produce output sample data.
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.1"
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
- if @input_file_name == nil
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
- if @output_file_name == nil
22
- @output_file_name = File.basename(@input_file_name, File.extname(@input_file_name)) + ".wav"
23
- end
23
+ song = normalize_for_pattern_option(song)
24
+ songs_to_generate = normalize_for_split_option(song)
24
25
 
25
- song_parser = SongParser.new()
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
- # If the -p option is used, transform the song into one whose flow consists of
30
- # playing that single pattern once.
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
- song.remove_patterns_except(pattern_name)
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
- duration = nil
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
- duration = AudioEngine.new(split_song, kit).write_to_file(file_name)
67
+
68
+ songs_to_generate[file_name] = split_song
48
69
  end
49
70
  else
50
- song = song_optimizer.optimize(song, OPTIMIZED_PATTERN_LENGTH)
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 {:duration => duration}
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, "w")
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 = song.patterns.keys.sort {|x, y| x.to_s <=> y.to_s }
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 = song.patterns[pattern_name]
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 song
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
- # This class is used to parse a raw YAML song definition into domain objects. These
4
- # domain objects can then be used by AudioEngine to generate the output sample data
5
- # for the song.
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
- # This will be coming in a future version...
30
- #unless raw_song_components[:folder] == nil
31
- # base_path = raw_song_components[:folder]
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 Structure, or has 0 repeats?
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 'X' or '.'"
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
 
@@ -1,6 +1,6 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__),'..','lib')
2
2
 
3
- require 'test/includes'
3
+ require 'includes'
4
4
 
5
5
  # Make private methods public for testing
6
6
  class MockAudioEngine < AudioEngine
@@ -1,6 +1,6 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__),'..','lib')
2
2
 
3
- require 'test/includes'
3
+ require 'includes'
4
4
 
5
5
  class AudioUtilsTest < Test::Unit::TestCase
6
6
  def test_composite
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 'test/includes'
3
+ require 'includes'
4
4
 
5
- class SongParserTest < Test::Unit::TestCase
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
- assert_equal(File.read(expected_output_file), File.read(actual_output_file))
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 'test/includes'
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
@@ -1,6 +1,6 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__),'..','lib')
2
2
 
3
- require 'test/includes'
3
+ require 'includes'
4
4
 
5
5
  class PatternTest < Test::Unit::TestCase
6
6
  SAMPLE_RATE = 44100
@@ -1,6 +1,6 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__),'..','lib')
2
2
 
3
- require 'test/includes'
3
+ require 'includes'
4
4
 
5
5
  class PatternExpanderTest < Test::Unit::TestCase
6
6
  def test_expand_pattern_no_repeats
data/test/song_test.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__),'..','lib')
2
2
 
3
- require 'test/includes'
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()
@@ -1,6 +1,6 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__),'..','lib')
2
2
 
3
- require 'test/includes'
3
+ require 'includes'
4
4
 
5
5
  class MockSongOptimizer < SongOptimizer
6
6
  def clone_song_ignoring_patterns_and_flow(original_song)
@@ -1,6 +1,6 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__),'..','lib')
2
2
 
3
- require 'test/includes'
3
+ require 'includes'
4
4
 
5
5
  class SongParserTest < Test::Unit::TestCase
6
6
  FIXTURE_BASE_PATH = File.dirname(__FILE__) + "/.."
data/test/track_test.rb CHANGED
@@ -1,6 +1,6 @@
1
1
  $:.unshift File.join(File.dirname(__FILE__),'..','lib')
2
2
 
3
- require 'test/includes'
3
+ require 'includes'
4
4
 
5
5
  class TrackTest < Test::Unit::TestCase
6
6
  def generate_test_data
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
- segments:
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-03-06 00:00:00 -05:00
19
- default_executable:
20
- dependencies: []
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/integration.rb
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.6.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/integration.rb
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