waveform 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (6) hide show
  1. data/README.md +101 -0
  2. data/Rakefile +5 -0
  3. data/bin/waveform +58 -0
  4. data/lib/waveform.rb +303 -0
  5. data/waveform.gemspec +26 -0
  6. metadata +80 -0
data/README.md ADDED
@@ -0,0 +1,101 @@
1
+ Waveform
2
+ ========
3
+
4
+ Waveform is a class to generate waveform images from audio files. You can
5
+ combine it with jPlayer to make a soundcloud.com style MP3 player. It also
6
+ comes with a handy CLI you can use to generate waveform images on the command
7
+ line.
8
+
9
+ CLI Usage
10
+ =========
11
+
12
+ $ waveform song.mp3 waveform.png
13
+
14
+ There are some nifty options you can supply to switch things up:
15
+
16
+ -W sets the width (in pixels) of the waveform image.
17
+ -H sets the height (in pixels).
18
+ -c sets the color used to draw the waveform (in hex, can also use
19
+ 'transparent').
20
+ -b sets the background color to draw the waveform on (in hex, and can use
21
+ 'transparent' as well).
22
+ -m sets the method used to sample the source audio file, it can either be
23
+ 'peak' or 'rms'. 'peak' is probably what you want because it looks
24
+ cooler, but 'rms' is closer to what you actually hear.
25
+
26
+ There's also some less-nifty options:
27
+
28
+ -q will generate your waveform without printing out a bunch of stuff.
29
+ -h will prit out a help screen with all this info.
30
+
31
+ Generating a small waveform "cut out" of a white background is pretty useful,
32
+ then you can overlay it on a web-gradient on the website for your new startup
33
+ and it will look really cool. To make it you could use:
34
+
35
+ $ waveform -W900 -H140 -ctransparent -b#ffffff Motley\ Crüe/Kickstart\ my\ Heart.mp3 sweet_waveforms/Kickstart\ my\ Heart.png
36
+
37
+ Usage in code
38
+ =============
39
+
40
+ The CLI is really just a thin wrapper around the Waveform class, which you can
41
+ also use in your programs for reasons I haven't thought of. The Waveform class
42
+ takes pretty much the same options as the CLI when generating waveforms.
43
+
44
+ Requirements
45
+ ============
46
+
47
+ `ruby-audio`
48
+
49
+ The gem version, *not* the old outdated library listed on RAA. `ruby-audio` is
50
+ a wrapper for `libsndfile`, on my Ubuntu 10.04LTS VM I installed the necessary
51
+ libs to build `ruby-audio` via: `sudo apt-get install libsndfile1-dev`.
52
+
53
+ `chunky_png`
54
+
55
+ `chunky_png` is a pure ruby (!) PNG manipulation library. Caveat to this
56
+ requirement is that if you also install `oily_png` you will get *better
57
+ performance* as it uses some C code, and C code is fast.
58
+
59
+ `ffmpeg` (sorta)
60
+
61
+ You only need `ffmpeg` if you plan to generate waveforms from files that aren't
62
+ already WAVs (like MP3, or M4A). On my same Ubuntu VM I installed it via `sudo
63
+ apt-get install ffmpeg` and it was able to convert MP3 and M4A files out of the
64
+ box. The formats you can convert depend on which decoders you have installed.
65
+
66
+ If you don't want to install ffmpeg, you could also use one of the many audio
67
+ format converters to convert your files to WAV before generating waveforms.
68
+
69
+ Or you could be all retro and use WAV audio for everything in the first place.
70
+
71
+ Some notes
72
+ ==========
73
+
74
+ I threw the original version of this together in a day, and then made this
75
+ second version in another couple. During those days I committed a cardinal sin
76
+ and didn't write any tests, because decoding sound files and drawing pictures
77
+ of them is more fun than writing tests. `ChunkyPNG` is cool though and will let
78
+ you read raw pixel data, so it should be pretty easy to write some tests that
79
+ actually read the pixel data of a waveform generated from a known source and
80
+ ensure everything went according to plan. I'll do that later.
81
+
82
+ Also, please refactor this/make it faster.
83
+
84
+ References
85
+ ==========
86
+
87
+ <http://pscode.org/javadoc/src-html/org/pscode/ui/audiotrace/AudioPlotPanel.html#line.996>
88
+ <http://github.com/pangdudu/rude/blob/master/lib/waveform_narray_testing.rb>
89
+ <http://stackoverflow.com/questions/1931952/asp-net-create-waveform-image-from-mp3>
90
+ <http://codeidol.com/java/swing/Audio/Build-an-Audio-Waveform-Display>
91
+
92
+ License
93
+ =======
94
+
95
+ Copyright (c) 2010 Ben Alavi
96
+
97
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
98
+
99
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
100
+
101
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/Rakefile ADDED
@@ -0,0 +1,5 @@
1
+ task :default => :test
2
+
3
+ task :test do
4
+ system "cutest test/*.rb"
5
+ end
data/bin/waveform ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env ruby
2
+ require "waveform"
3
+ require "optparse"
4
+
5
+ options = Waveform::DefaultOptions
6
+ optparse = OptionParser.new do |o|
7
+ o.banner = "Usage: waveform [options] source_audio [ouput.png]"
8
+
9
+ o.on("-W", "--width WIDTH", "Width (in pixels) of generated waveform image -- Default #{Waveform::DefaultOptions[:width]}.") do |width|
10
+ options[:width] = width.to_i
11
+ end
12
+
13
+ o.on("-H", "--height HEIGHT", "Height (in pixels) of generated waveform image -- Default #{Waveform::DefaultOptions[:height]}.") do |height|
14
+ options[:height] = height.to_i
15
+ end
16
+
17
+ o.on("-c", "--color COLOR", "Color (hex code) to draw the waveform. Can also pass 'transparent' to cut it out of the background -- Default #{Waveform::DefaultOptions[:color]}.") do |color|
18
+ if color == "transparent"
19
+ options[:color] = :transparent
20
+ else
21
+ options[:color] = color.match(/#.*/) ? color : "##{color}"
22
+ end
23
+ end
24
+
25
+ o.on("-b", "--background COLOR", "Background color (hex code) to draw waveform on -- Default #{Waveform::DefaultOptions[:background_color]}.") do |color|
26
+ if color == "transparent"
27
+ options[:background_color] = :transparent
28
+ else
29
+ options[:background_color] = color.match(/#.*/) ? color : "##{color}"
30
+ end
31
+ end
32
+
33
+ o.on("-m", "--method METHOD", "Wave analyzation method (can be 'peak' or 'rms') -- Default '#{Waveform::DefaultOptions[:method]}'.") do |method|
34
+ options[:method] = method.to_sym
35
+ end
36
+
37
+ options[:quiet] = false
38
+ o.on("-q", "--quiet", "Don't print anything out when generating waveform") do
39
+ options[:quiet] = true
40
+ end
41
+
42
+ o.on("-h", "--help", "Display this screen") do
43
+ puts o
44
+ exit
45
+ end
46
+ end
47
+
48
+ optparse.parse!
49
+
50
+ begin
51
+ Waveform.new(ARGV[0], options[:quiet] ? nil : $stdout).generate(ARGV[1] || "waveform.png", options)
52
+ rescue Waveform::ArgumentError => e
53
+ puts e.message + "\n\n"
54
+ puts optparse
55
+ rescue Waveform::RuntimeError => e
56
+ puts e.message
57
+ end
58
+
data/lib/waveform.rb ADDED
@@ -0,0 +1,303 @@
1
+ require "ruby-audio"
2
+
3
+ begin
4
+ require "oily_png"
5
+ rescue LoadError
6
+ require "chunky_png"
7
+ end
8
+
9
+ class Waveform
10
+ VERSION = "0.0.1"
11
+
12
+ DefaultOptions = {
13
+ :method => :peak,
14
+ :width => 1800,
15
+ :height => 280,
16
+ :background_color => "#666666",
17
+ :color => "#00ccff"
18
+ }
19
+
20
+ TransparencyMask = "#00ff00"
21
+ TransparencyAlternate = "#ffff00" # in case the mask is the background color!
22
+
23
+ attr_reader :audio
24
+
25
+ # Scope these under Waveform so you can catch the ones generated by just this
26
+ # class.
27
+ class RuntimeError < ::RuntimeError;end;
28
+ class ArgumentError < ::ArgumentError;end;
29
+
30
+ # Setup a new Waveform for the given audio file. If given anything besides a
31
+ # WAV file it will attempt to first convert the file to a WAV using ffmpeg.
32
+ #
33
+ # Optionally takes an IO stream to which it will print log/benchmarking info.
34
+ #
35
+ # See #generate for how to generate the waveform image from the given audio
36
+ # file.
37
+ #
38
+ # Available conversions depend on your installation of ffmpeg.
39
+ #
40
+ # Example:
41
+ #
42
+ # Waveform.new("mp3s/Kickstart My Heart.mp3")
43
+ # Waveform.new("mp3s/Kickstart My Heart.mp3", $stdout)
44
+ #
45
+ def initialize(audio, log=nil)
46
+ raise ArgumentError.new("No source audio filename given, must be an existing sound file.") unless audio
47
+ raise RuntimeError.new("Source audio file '#{audio}' not found.") unless File.exist?(audio)
48
+
49
+ @log = Log.new(log)
50
+
51
+ if File.extname(audio) != ".wav"
52
+ @audio = audio.sub /(.+)\.(.+)/, "\\1.wav"
53
+ raise RuntimeError.new("Unable to decode source '#{audio}' to WAV. Do you have ffmpeg installed with an appropriate decoder for your source file?") unless to_wav(audio, @audio)
54
+ else
55
+ @audio = audio
56
+ end
57
+ end
58
+
59
+ # Generate a Waveform image at the given filename with the given options.
60
+ #
61
+ # Available options are:
62
+ #
63
+ # :method => The method used to read sample frames, available methods
64
+ # are peak and rms. peak is probably what you're used to seeing, it uses
65
+ # the maximum amplitude per sample to generate the waveform, so the
66
+ # waveform looks more dynamic. RMS gives a more fluid waveform and
67
+ # probably more accurately reflects what you hear, but isn't as
68
+ # pronounced (typically).
69
+ #
70
+ # Can be :rms or :peak
71
+ # Default is :peak.
72
+ #
73
+ # :width => The width (in pixels) of the final waveform image.
74
+ # Default is 1800.
75
+ #
76
+ # :height => The height (in pixels) of the final waveform image.
77
+ # Default is 280.
78
+ #
79
+ # :background_color => Hex code of the background color of the generated
80
+ # waveform image.
81
+ # Default is #666666 (gray).
82
+ #
83
+ # :color => Hex code of the color to draw the waveform, or can pass
84
+ # :transparent to render the waveform transparent (use w/ a solid
85
+ # color background to achieve a "cutout" effect).
86
+ # Default is #00ccff (cyan-ish).
87
+ #
88
+ # Example:
89
+ # waveform = Waveform.new("mp3s/Kickstart My Heart.mp3")
90
+ #
91
+ # waveform.generate("waves/Kickstart My Heart.png")
92
+ # waveform.generate("waves/Kickstart My Heart.png", :method => :rms)
93
+ # waveform.generate("waves/Kickstart My Heart.png", :color => "#ff00ff")
94
+ #
95
+ def generate(filename, options={})
96
+ raise ArgumentError.new("No destination filename given for waveform") unless filename
97
+ raise RuntimeError.new("Destination file #{filename} exists") if File.exists?(filename)
98
+
99
+ options = DefaultOptions.merge(options)
100
+
101
+ @log.start!
102
+
103
+ # Frames gives the amplitudes for each channel, for our waveform we're
104
+ # saying the "visual" amplitude is the average of the amplitude across all
105
+ # the channels. This might be a little weird w/ the "peak" method if the
106
+ # frames are very wide (i.e. the image width is very small) -- I *think*
107
+ # the larger the frames are, the more "peaky" the waveform should get,
108
+ # perhaps to the point of inaccurately reflecting the actual sound.
109
+ samples = frames(options[:width], options[:method]).collect do |frame|
110
+ frame.inject(0.0) { |sum, peak| sum + peak } / frame.size
111
+ end
112
+
113
+ @log.timed("\nDrawing...") do
114
+ background_color = options[:background_color] == :transparent ? ChunkyPNG::Color::TRANSPARENT : options[:background_color]
115
+
116
+ if options[:color] == :transparent
117
+ color = transparent = ChunkyPNG::Color.from_hex(
118
+ # Have to do this little bit because it's possible the color we were
119
+ # intending to use a transparency mask *is* the background color, and
120
+ # then we'd end up wiping out the whole image.
121
+ options[:background_color].downcase == TransparencyMask ? TransparencyAlternate : TransparencyMask
122
+ )
123
+ else
124
+ color = ChunkyPNG::Color.from_hex(options[:color])
125
+ end
126
+
127
+ image = ChunkyPNG::Image.new(options[:width], options[:height], background_color)
128
+ # Calling "zero" the middle of the waveform, like there's positive and
129
+ # negative amplitude
130
+ zero = options[:height] / 2.0
131
+
132
+ samples.each_with_index do |sample, x|
133
+ # Half the amplitude goes above zero, half below
134
+ amplitude = sample * options[:height].to_f / 2.0
135
+ # If you give ChunkyPNG floats for pixel positions all sorts of things
136
+ # go haywire.
137
+ image.line(x, (zero - amplitude).round, x, (zero + amplitude).round, color)
138
+ end
139
+
140
+ # Simple transparency masking, it just loops over every pixel and makes
141
+ # ones which match the transparency mask color completely clear.
142
+ if transparent
143
+ (0..image.width - 1).each do |x|
144
+ (0..image.height - 1).each do |y|
145
+ image[x, y] = ChunkyPNG::Color.rgba(0, 0, 0, 0) if image[x, y] == transparent
146
+ end
147
+ end
148
+ end
149
+
150
+ image.save(filename)
151
+ end
152
+
153
+ @log.done!("Generated waveform '#{filename}'")
154
+ end
155
+
156
+ # Returns a sampling of frames from the given wave file using the given method
157
+ # the sample size is determined by the given pixel width -- we want one sample
158
+ # frame per horizontal pixel.
159
+ def frames(width, method = :peak)
160
+ raise ArgumentError.new("Unknown sampling method #{method}") unless [ :peak, :rms ].include?(method)
161
+
162
+ frames = []
163
+
164
+ RubyAudio::Sound.open(audio) do |snd|
165
+ frames_read = 0
166
+ frames_per_sample = (snd.info.frames.to_f / width.to_f).to_i
167
+ sample = RubyAudio::Buffer.new("float", frames_per_sample, snd.info.channels)
168
+
169
+ @log.timed("Sampling #{frames_per_sample} frames per sample: ") do
170
+ while(frames_read = snd.read(sample)) > 0
171
+ frames << send(method, sample, snd.info.channels)
172
+ @log.out(".")
173
+ end
174
+ end
175
+ end
176
+
177
+ frames
178
+ end
179
+
180
+ private
181
+
182
+ # Decode audio to a wav file, returns true if the decode succeeded or false
183
+ # otherwise.
184
+ def to_wav(src, dest)
185
+ @log.start!
186
+ @log.out("Decoding source audio '#{src}' to WAV...")
187
+
188
+ raise RuntimeError.new("Destination WAV file '#{dest}' exists!") if File.exists?(dest)
189
+
190
+ system %Q{ffmpeg -i "#{src}" -f wav "#{dest}" > /dev/null 2>&1}
191
+ @log.done!
192
+
193
+ File.exists?(dest)
194
+ end
195
+
196
+ # Returns an array of the peak of each channel for the given collection of
197
+ # frames -- the peak is individual to the channel, and the returned collection
198
+ # of peaks are not (necessarily) from the same frame(s).
199
+ def peak(frames, channels=1)
200
+ peak_frame = []
201
+ (0..channels-1).each do |channel|
202
+ peak_frame << channel_peak(frames, channel)
203
+ end
204
+ peak_frame
205
+ end
206
+
207
+ # Returns an array of rms values for the given frameset where each rms value is
208
+ # the rms value for that channel.
209
+ def rms(frames, channels=1)
210
+ rms_frame = []
211
+ (0..channels-1).each do |channel|
212
+ rms_frame << channel_rms(frames, channel)
213
+ end
214
+ rms_frame
215
+ end
216
+
217
+ # Returns the peak voltage reached on the given channel in the given collection
218
+ # of frames.
219
+ #
220
+ # TODO: Could lose some resolution and only sample every other frame, would
221
+ # likely still generate the same waveform as the waveform is so comparitively
222
+ # low resolution to the original input (in most cases), and would increase
223
+ # the analyzation speed (maybe).
224
+ def channel_peak(frames, channel=0)
225
+ peak = 0.0
226
+ frames.each do |frame|
227
+ next if frame.nil?
228
+ peak = frame[channel].abs if frame[channel].abs > peak
229
+ end
230
+ peak
231
+ end
232
+
233
+ # Returns the rms value across the given collection of frames for the given
234
+ # channel.
235
+ #
236
+ # FIXME: this RMS calculation might be wrong...
237
+ # refactored this from: http://pscode.org/javadoc/src-html/org/pscode/ui/audiotrace/AudioPlotPanel.html#line.996
238
+ def channel_rms(frames, channel=0)
239
+ avg = frames.inject(0.0){ |sum, frame| sum += frame ? frame[channel] : 0 }/frames.size.to_f
240
+ Math.sqrt(frames.inject(0.0){ |sum, frame| sum += frame ? (frame[channel]-avg)**2 : 0 }/frames.size.to_f)
241
+ end
242
+ end
243
+
244
+ class Waveform
245
+ # A simple class for logging + benchmarking, nice to have good feedback on a
246
+ # long batch operation.
247
+ #
248
+ # There's probably 10,000,000 other bechmarking classes, but writing this was
249
+ # easier than using Google.
250
+ class Log
251
+ attr_accessor :io
252
+
253
+ def initialize(io=$stdout)
254
+ @io = io
255
+ end
256
+
257
+ # Prints the given message to the log
258
+ def out(msg)
259
+ io.print(msg) if io
260
+ end
261
+
262
+ # Prints the given message to the log followed by the most recent benchmark
263
+ # (note that it calls .end! which will stop the benchmark)
264
+ def done!(msg="")
265
+ out "#{msg} (#{self.end!}s)\n"
266
+ end
267
+
268
+ # Starts a new benchmark clock and returns the index of the new clock.
269
+ #
270
+ # If .start! is called again before .end! then the time returned will be
271
+ # the elapsed time from the next call to start!, and calling .end! again
272
+ # will return the time from *this* call to start! (that is, the clocks are
273
+ # LIFO)
274
+ def start!
275
+ (@benchmarks ||= []) << Time.now
276
+ @current = @benchmarks.size - 1
277
+ end
278
+
279
+ # Returns the elapsed time from the most recently started benchmark clock
280
+ # and ends the benchmark, so that a subsequent call to .end! will return
281
+ # the elapsed time from the previously started benchmark clock.
282
+ def end!
283
+ elapsed = (Time.now - @benchmarks[@current])
284
+ @current -= 1
285
+ elapsed
286
+ end
287
+
288
+ # Returns the elapsed time from the benchmark clock w/ the given index (as
289
+ # returned from when .start! was called).
290
+ def time?(index)
291
+ Time.now - @benchmarks[index]
292
+ end
293
+
294
+ # Benchmarks the given block, printing out the given message first (if
295
+ # given).
296
+ def timed(message=nil, &block)
297
+ start!
298
+ out(message) if message
299
+ yield
300
+ done!
301
+ end
302
+ end
303
+ end
data/waveform.gemspec ADDED
@@ -0,0 +1,26 @@
1
+ require "./lib/waveform"
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "waveform"
5
+ s.version = Waveform::VERSION
6
+ s.summary = "Generate waveform images from WAV and MP3 files"
7
+ s.description = "Generate waveform images from WAV and MP3 files -- in your code or via included CLI."
8
+ s.authors = ["Ben Alavi"]
9
+ s.email = ["benalavi@gmail.com"]
10
+ s.homepage = "http://github.com/benalavi/waveform"
11
+
12
+ s.files = Dir[
13
+ "LICENSE",
14
+ "README.md",
15
+ "Rakefile",
16
+ "lib/**/*.rb",
17
+ "*.gemspec",
18
+ "test/**/*.rb",
19
+ "bin/*"
20
+ ]
21
+
22
+ s.executables = "waveform"
23
+
24
+ s.add_dependency "ruby-audio"
25
+ s.add_dependency "chunky_png"
26
+ end
metadata ADDED
@@ -0,0 +1,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: waveform
3
+ version: !ruby/object:Gem::Version
4
+ prerelease:
5
+ version: 0.0.1
6
+ platform: ruby
7
+ authors:
8
+ - Ben Alavi
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+
13
+ date: 2011-07-16 00:00:00 Z
14
+ dependencies:
15
+ - !ruby/object:Gem::Dependency
16
+ name: ruby-audio
17
+ prerelease: false
18
+ requirement: &id001 !ruby/object:Gem::Requirement
19
+ none: false
20
+ requirements:
21
+ - - ">="
22
+ - !ruby/object:Gem::Version
23
+ version: "0"
24
+ type: :runtime
25
+ version_requirements: *id001
26
+ - !ruby/object:Gem::Dependency
27
+ name: chunky_png
28
+ prerelease: false
29
+ requirement: &id002 !ruby/object:Gem::Requirement
30
+ none: false
31
+ requirements:
32
+ - - ">="
33
+ - !ruby/object:Gem::Version
34
+ version: "0"
35
+ type: :runtime
36
+ version_requirements: *id002
37
+ description: Generate waveform images from WAV and MP3 files -- in your code or via included CLI.
38
+ email:
39
+ - benalavi@gmail.com
40
+ executables:
41
+ - waveform
42
+ extensions: []
43
+
44
+ extra_rdoc_files: []
45
+
46
+ files:
47
+ - README.md
48
+ - Rakefile
49
+ - lib/waveform.rb
50
+ - waveform.gemspec
51
+ - bin/waveform
52
+ homepage: http://github.com/benalavi/waveform
53
+ licenses: []
54
+
55
+ post_install_message:
56
+ rdoc_options: []
57
+
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ none: false
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: "0"
66
+ required_rubygems_version: !ruby/object:Gem::Requirement
67
+ none: false
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: "0"
72
+ requirements: []
73
+
74
+ rubyforge_project:
75
+ rubygems_version: 1.8.5
76
+ signing_key:
77
+ specification_version: 3
78
+ summary: Generate waveform images from WAV and MP3 files
79
+ test_files: []
80
+