class CarrierWave::AudioWaveform::Waveformer

Constants

DefaultOptions
TransparencyAlternate
TransparencyMask

Attributes

source[R]

Public Class Methods

generate(source, options={}) click to toggle source

Generate a Waveform image at the given filename with the given options.

Available options (all optional) are:

:method => The method used to read sample frames, available methods
  are peak and rms. peak is probably what you're used to seeing, it uses
  the maximum amplitude per sample to generate the waveform, so the
  waveform looks more dynamic. RMS gives a more fluid waveform and
  probably more accurately reflects what you hear, but isn't as
  pronounced (typically).

  Can be :rms or :peak
  Default is :peak.

:width => The width (in pixels) of the final waveform image.
  Default is 1800.

:height => The height (in pixels) of the final waveform image.
  Default is 280.

:auto_width => msec per pixel. This will overwrite the width of the
  final waveform image depending on the length of the audio file.
  Example:
    100 => 1 pixel per 100 msec; a one minute audio file will result in a width of 600 pixels

:background_color => Hex code of the background color of the generated
  waveform image. Pass :transparent for transparent background.
  Default is #666666 (gray).

:color => Hex code of the color to draw the waveform, or can pass
  :transparent to render the waveform transparent (use w/ a solid
  color background to achieve a "cutout" effect).
  Default is #00ccff (cyan-ish).

:sample_width => Integer specifying the sample width. If this
  is specified, there will be gaps (minimum of 1px wide, as specified
  by :gap_width) between samples that are this wide in pixels.
  Default is nil
  Minimum is 1 (for anything other than nil)

:gap_width => Integer specifying the gap width. If sample_width
  is specified, this will be the size of the gaps between samples in pixels.
  Default is nil
  Minimum is 1 (for anything other than nil, or when sample_width is present but gap_width is not)

:logger => IOStream to log progress to.

Example:

CarrierWave::AudioWaveform::Waveformer.generate("Kickstart My Heart.wav")
CarrierWave::AudioWaveform::Waveformer.generate("Kickstart My Heart.wav", :method => :rms)
CarrierWave::AudioWaveform::Waveformer.generate("Kickstart My Heart.wav", :color => "#ff00ff", :logger => $stdout)
# File lib/carrierwave/audio_waveform/waveformer.rb, line 82
def generate(source, options={})
  options = DefaultOptions.merge(options)
  filename = options[:filename] || self.generate_image_filename(source, options[:type])

  raise ArgumentError.new("No source audio filename given, must be an existing sound file.") unless source
  raise ArgumentError.new("No destination filename given for waveform") unless filename
  raise RuntimeError.new("Source audio file '#{source}' not found.") unless File.exist?(source)

  old_source = source
  source = generate_wav_source(source)

  @log = Log.new(options[:logger])
  @log.start!

  if options[:auto_width]
    RubyAudio::Sound.open(source) do |audio|
      options[:width] = (audio.info.length * 1000 / options[:auto_width].to_i).ceil
    end
  end

  # Frames gives the amplitudes for each channel, for our waveform we're
  # saying the "visual" amplitude is the average of the amplitude across all
  # the channels. This might be a little weird w/ the "peak" method if the
  # frames are very wide (i.e. the image width is very small) -- I *think*
  # the larger the frames are, the more "peaky" the waveform should get,
  # perhaps to the point of inaccurately reflecting the actual sound.
  samples = frames(source, options[:width], options[:method]).collect do |frame|
    frame.inject(0.0) { |sum, peak| sum + peak } / frame.size      
  end

  @log.timed("\nDrawing...") do
    # Don't remove the file until we're sure the
    # source was readable
    if File.exists?(filename)
      @log.out("Output file #{filename} encountered. Removing.")
      File.unlink(filename)
    end

    image = draw samples, options

    if options[:type] == :svg
      File.open(filename, 'w') do |f|
        f.puts image
      end
    else
      image.save filename
    end
  end

  if source != old_source
    @log.out("Removing temporary file at #{source}")
    FileUtils.rm(source)
  end

  @log.done!("Generated waveform '#{filename}'")

  filename
end
generate_image_filename(source, image_type) click to toggle source
# File lib/carrierwave/audio_waveform/waveformer.rb, line 141
def generate_image_filename(source, image_type)
  ext = File.extname(source)
  source_file_path_without_extension = File.join File.dirname(source), File.basename(source, ext)

  if image_type == :svg
    "#{source_file_path_without_extension}.svg"
  else
    "#{source_file_path_without_extension}.png"
  end
end

Private Class Methods

calculate_avg_sample(samples, current_sample_index, sample_width) click to toggle source

Calculate the average of a group of samples Return the sample's value if it's a group of 1

# File lib/carrierwave/audio_waveform/waveformer.rb, line 363
def calculate_avg_sample(samples, current_sample_index, sample_width)
  if sample_width > 1
    floats = samples[current_sample_index..(current_sample_index + sample_width - 1)].collect(&:to_f)
    #floats.inject(:+) / sample_width
    channel_rms(floats)
  else
    samples[current_sample_index]
  end
end
channel_peak(frames, channel=0) click to toggle source

Returns the peak voltage reached on the given channel in the given collection of frames.

TODO: Could lose some resolution and only sample every other frame, would likely still generate the same waveform as the waveform is so comparitively low resolution to the original input (in most cases), and would increase the analyzation speed (maybe).

# File lib/carrierwave/audio_waveform/waveformer.rb, line 401
def channel_peak(frames, channel=0)
  peak = 0.0
  frames.each do |frame|
    next if frame.nil?
    frame = Array(frame)
    peak = frame[channel].abs if frame[channel].abs > peak
  end
  peak
end
channel_rms(frames, channel=0) click to toggle source

Returns the rms value across the given collection of frames for the given channel.

# File lib/carrierwave/audio_waveform/waveformer.rb, line 413
def channel_rms(frames, channel=0)
  Math.sqrt(frames.inject(0.0){ |sum, frame| sum += (frame ? Array(frame)[channel] ** 2 : 0) } / frames.size)
end
draw(samples, options) click to toggle source
# File lib/carrierwave/audio_waveform/waveformer.rb, line 205
def draw(samples, options)
  if options[:type] == :svg
    draw_svg(samples, options)
  else
    draw_png(samples, options)
  end
end
draw_png(samples, options) click to toggle source

Draws the given samples using the given options, returns a ChunkyPNG::Image.

# File lib/carrierwave/audio_waveform/waveformer.rb, line 276
def draw_png(samples, options)
  image = ChunkyPNG::Image.new(options[:width], options[:height],
    options[:background_color] == :transparent ? ChunkyPNG::Color::TRANSPARENT : options[:background_color]
  )

  if options[:color] == :transparent
    color = transparent = ChunkyPNG::Color.from_hex(
      # Have to do this little bit because it's possible the color we were
      # intending to use a transparency mask *is* the background color, and
      # then we'd end up wiping out the whole image.
      options[:background_color].downcase == TransparencyMask ? TransparencyAlternate : TransparencyMask
    )
  else
    color = ChunkyPNG::Color.from_hex(options[:color])
  end

  # Calling "zero" the middle of the waveform, like there's positive and
  # negative amplitude
  zero = options[:height] / 2.0

  # If a sample_width is passed, let's space those things out
  if options[:sample_width]
    samples = spaced_samples(samples, options[:sample_width], options[:gap_width])
  end

  samples.each_with_index do |sample, x|
    next if sample.nil?
    # Half the amplitude goes above zero, half below
    amplitude = sample * options[:height].to_f / 2.0
    # If you give ChunkyPNG floats for pixel positions all sorts of things
    # go haywire.
    image.line(x, (zero - amplitude).round, x, (zero + amplitude).round, color)
  end

  # Simple transparency masking, it just loops over every pixel and makes
  # ones which match the transparency mask color completely clear.
  if transparent
    (0..image.width - 1).each do |x|
      (0..image.height - 1).each do |y|
        image[x, y] = ChunkyPNG::Color.rgba(0, 0, 0, 0) if image[x, y] == transparent
      end
    end
  end

  image
end
draw_svg(samples, options) click to toggle source
# File lib/carrierwave/audio_waveform/waveformer.rb, line 213
def draw_svg(samples, options)
  wave_image    = ""
  samples       = spaced_samples(samples, options[:sample_width], options[:gap_width]) if options[:sample_width]
  height_factor = (options[:height] * 0.85 / 2.0)

  bar_pos = 0
  samples.each_with_index do |sample, pos|
    next if sample.nil?

    if (pos%3 == 0)
      amplitude = sample * height_factor
      top       = (0 - amplitude).round
      bottom    = (0 + amplitude).round

      wave_image+= "M#{bar_pos},#{top}V#{bottom}"

      bar_pos += (1 + options[:gap_width])
    end
  end

  image = "<svg xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3/org/1999/xlink\" viewbox=\"0 0 #{bar_pos+1} #{options[:height]}\" preserveAspectRatio=\"none\" width=\"100%\" height=\"100%\">"          
  if (options[:hide_style].nil? || options[:hide_style] == false)
    image+= "<style>"
    image+= "svg {"
    image+= "stroke: #000;"
    image+= "stroke-width: 0.25;"
    image+= "}"
    image+= "use.waveform-progress {"
    image+= "stroke-width: 0.25;"
    image+= "clip-path: polygon(0% 0%, 0% 0%, 0% 100%, 0% 100%);"
    image+= "}"
    image+= "svg path {"
    image+= "stroke: inherit;"
    image+= "stroke-width: inherit;"
    image+= "}"
    image+= "</style>"
  end
  image+= "<defs>"
  if options[:gradient]
    options[:gradient].each_with_index do |grad, id|
      image+= "<linearGradient id=\"linear#{id}\" x1=\"0%\" y1=\"0%\" x2=\"100%\" y2=\"0%\">"
      image+= "<stop offset=\"0%\" stop-color=\"#{grad[0]}\"/>"
      image+= "<stop offset=\"100%\" stop-color=\"#{grad[1]}\"/>"
      image+= "</linearGradient>"
    end
  end
  uniqueWaveformID = "waveform-#{SecureRandom.uuid}"
  image+= "<g id=\"#{uniqueWaveformID}\">"
  image+= "<g transform=\"translate(0, #{options[:height] / 2.0})\">"
  image+= '<path stroke="currrentColor" d="'

  image+= wave_image

  image+= '"/>'
  image+= "</g>"
  image+= "</g>"
  image+= "</defs>"
  image+= "<use class=\"waveform-base\" href=\"##{uniqueWaveformID}\" />"
  image+= "<use class=\"waveform-progress\" href=\"##{uniqueWaveformID}\" />"
  image+= "</svg>"
end
frames(source, width, method = :peak) click to toggle source

Returns a sampling of frames from the given RubyAudio::Sound using the given method the sample size is determined by the given pixel width – we want one sample frame per horizontal pixel.

# File lib/carrierwave/audio_waveform/waveformer.rb, line 181
def frames(source, width, method = :peak)
  raise ArgumentError.new("Unknown sampling method #{method}") unless [ :peak, :rms ].include?(method)

  frames = []

  RubyAudio::Sound.open(source) do |audio|
    frames_read = 0
    frames_per_sample = (audio.info.frames.to_f / width.to_f).to_i
    sample = RubyAudio::Buffer.new("float", frames_per_sample, audio.info.channels)

    @log.timed("Sampling #{frames_per_sample} frames per sample: ") do
      while(frames_read = audio.read(sample)) > 0
        frames << send(method, sample, audio.info.channels)
        @log.out(".")
      end
    end
  end

  frames
rescue RubyAudio::Error => e
  raise e unless e.message == "File contains data in an unknown format."
  raise RuntimeError.new("Source audio file #{source} could not be read by RubyAudio library -- Hint: non-WAV files are no longer supported, convert to WAV first using something like ffmpeg (RubyAudio: #{e.message})")
end
generate_wav_source(source) click to toggle source

Returns a wav file if one was not passed in, or the original if it was

# File lib/carrierwave/audio_waveform/waveformer.rb, line 156
def generate_wav_source(source)
  ext = File.extname(source)
  ext_gsubbed = ext.gsub(/\./, '')

  if ext != ".wav"
    input_options = { type: ext_gsubbed }
    output_options = { type: "wav" }
    source_filename_without_extension = File.basename(source, ext)
    output_file_path = File.join File.dirname(source), "tmp_#{source_filename_without_extension}_#{Time.now.to_i}.wav"
    converter = Sox::Cmd.new
    converter.add_input source, input_options
    converter.set_output output_file_path, output_options
    converter.run
    output_file_path
  else
    source
  end
rescue Sox::Error => e
  raise e unless e.message.include?("FAIL formats:")
  raise RuntimeError.new("Source file #{source} could not be converted to .wav by Sox (Sox: #{e.message})")
end
peak(frames, channels=1) click to toggle source

Returns an array of the peak of each channel for the given collection of frames – the peak is individual to the channel, and the returned collection of peaks are not (necessarily) from the same frame(s).

# File lib/carrierwave/audio_waveform/waveformer.rb, line 376
def peak(frames, channels=1)
  peak_frame = []
  (0..channels-1).each do |channel|
    peak_frame << channel_peak(frames, channel)
  end
  peak_frame
end
rms(frames, channels=1) click to toggle source

Returns an array of rms values for the given frameset where each rms value is the rms value for that channel.

# File lib/carrierwave/audio_waveform/waveformer.rb, line 386
def rms(frames, channels=1)
  rms_frame = []
  (0..channels-1).each do |channel|
    rms_frame << channel_rms(frames, channel)
  end
  rms_frame
end
spaced_samples(samples, sample_width = 1, gap_width = 1) click to toggle source
# File lib/carrierwave/audio_waveform/waveformer.rb, line 323
def spaced_samples samples, sample_width = 1, gap_width = 1
  sample_width = sample_width.to_i >= 1 ? sample_width.to_i : 1
  gap_width = gap_width.to_i >= 0 ? gap_width.to_i : 1
  width_counter = sample_width
  current_sample_index = 0
  spaced_samples = []
  avg = nil
  while samples[current_sample_index]
    at_front_of_image = current_sample_index < sample_width

    # This determines if it's a gap, but we don't want
    # a gap to start with, hence the last booelan check
    if width_counter.to_i > sample_width.to_i && !at_front_of_image
      # This is a gap
      spaced_samples << nil
      width_counter -= 1
    else
      # This is a sample
      # If this is a new block of samples, get the average
      if avg.nil?
        avg = calculate_avg_sample(samples, current_sample_index, sample_width)
      end
      spaced_samples << avg
      # This is 1-indexed since it starts at sample_width
      # (or sample_width + gap_width for anything other than the initial passes)
      if width_counter.to_i < 2
        width_counter = sample_width + gap_width
        avg = nil
      else
        width_counter -= 1
      end
    end
    current_sample_index += 1
  end

  spaced_samples
end