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 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